From 9490f9b318d72d52b077824e52d6e6fa6906cb1c Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Jun 2022 11:46:36 +0300 Subject: [PATCH 01/99] Add unified libraries --- package.json | 5 + yarn.lock | 336 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 333 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 98b9cb324..fe79e27e0 100644 --- a/package.json +++ b/package.json @@ -53,11 +53,16 @@ "react-with-direction": "^1.4.0", "redux": "^4.2.0", "redux-thunk": "^2.4.1", + "rehype-react": "^6.2.1", + "rehype-sanitize": "^4.0.0", + "remark-parse": "^9.0.0", + "remark-rehype": "^8.1.0", "seedrandom": "^3.0.5", "sharetribe-flex-sdk": "^1.17.0", "sharetribe-scripts": "6.0.1", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.21", + "unified": "^9.2.2", "url": "^0.11.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index e665897f8..963822e80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2604,6 +2604,13 @@ dependencies: make-dir "^3.0.2" +"@mapbox/hast-util-table-cell-style@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@mapbox/hast-util-table-cell-style/-/hast-util-table-cell-style-0.2.0.tgz#1003f59d54fae6f638cb5646f52110fb3da95b4d" + integrity sha512-gqaTIGC8My3LVSnU38IwjHVKJC94HSonjvFHDk8/aSrApL8v4uWgm8zJkK7MJIIbHuNOr/+Mv2KkQKcxs6LEZA== + dependencies: + unist-util-visit "^1.4.1" + "@mapbox/polyline@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@mapbox/polyline/-/polyline-1.1.1.tgz#ab96e5e6936f4847a4894e14558daf43e40e3bd2" @@ -3118,6 +3125,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/mdast@^3.0.0": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.10.tgz#4724244a82a4598884cbbe9bcfd73dff927ee8af" + integrity sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA== + dependencies: + "@types/unist" "*" + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -3244,6 +3258,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== +"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" + integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== + "@types/ws@^8.5.1": version "8.5.3" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" @@ -4090,6 +4109,11 @@ babel-preset-react-app@^10.0.1: babel-plugin-macros "^3.1.0" babel-plugin-transform-react-remove-prop-types "^0.4.24" +bail@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" + integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -4403,6 +4427,21 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.1.tgz#6dafdb25f9d3349914079f010ba8d0e6ff9cd01e" integrity sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw== +character-entities-legacy@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" + integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== + +character-entities@^1.0.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" + integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw== + +character-reference-invalid@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" + integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== + chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" @@ -4592,6 +4631,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comma-separated-tokens@^1.0.0: + version "1.0.8" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" + integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== + commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -5095,6 +5139,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.0.0, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" @@ -5102,13 +5153,6 @@ debug@^4.1.1: dependencies: ms "2.1.2" -debug@^4.3.2, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - decamelize-keys@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" @@ -6191,6 +6235,11 @@ express@^4.17.3, express@^4.18.1: utils-merge "1.0.1" vary "~1.1.2" +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + external-editor@^3.0.3: version "3.1.0" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" @@ -6809,6 +6858,26 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hast-to-hyperscript@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz#9b67fd188e4c81e8ad66f803855334173920218d" + integrity sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA== + dependencies: + "@types/unist" "^2.0.3" + comma-separated-tokens "^1.0.0" + property-information "^5.3.0" + space-separated-tokens "^1.0.0" + style-to-object "^0.3.0" + unist-util-is "^4.0.0" + web-namespaces "^1.0.0" + +hast-util-sanitize@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/hast-util-sanitize/-/hast-util-sanitize-3.0.2.tgz#b0b783220af528ba8fe6999f092d138908678520" + integrity sha512-+2I0x2ZCAyiZOO/sb4yNLFmdwPBnyJ4PBkVTUMKMqBwYNA+lXSgOmoRXlJFazoyid9QPogRRKgKhVEodv181sA== + dependencies: + xtend "^4.0.0" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -7133,6 +7202,11 @@ ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== +inline-style-parser@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" + integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== + inquirer@^8.2.4: version "8.2.4" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.4.tgz#ddbfe86ca2f67649a67daa6f1051c128f684f0b4" @@ -7190,6 +7264,19 @@ ipaddr.js@^2.0.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== +is-alphabetical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" + integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg== + +is-alphanumerical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf" + integrity sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A== + dependencies: + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -7222,6 +7309,11 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-buffer@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-callable@^1.1.4, is-callable@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" @@ -7268,6 +7360,11 @@ is-date-object@^1.0.1: resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== +is-decimal@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" + integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw== + is-docker@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.1.1.tgz#4125a88e44e450d384e09047ede71adc2d144156" @@ -7312,6 +7409,11 @@ is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-hexadecimal@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" + integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== + is-installed-globally@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" @@ -7380,6 +7482,11 @@ is-plain-obj@^1.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= +is-plain-obj@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + is-plain-obj@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" @@ -8480,6 +8587,43 @@ mapbox-gl-multitouch@^1.0.3: resolved "https://registry.yarnpkg.com/mapbox-gl-multitouch/-/mapbox-gl-multitouch-1.0.3.tgz#db8bbe86a15d8398e3315d97305c9edde3f0f0d7" integrity sha512-lpTFL2Sp7hK867mkMOZe2DvdS5eEHxWfMc7aSWCRDMgSq9IjPubsiix3FPs+IqcbkYmR+IUrzvH9RWBOXVs2cg== +mdast-util-definitions@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz#c5c1a84db799173b4dcf7643cda999e440c24db2" + integrity sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ== + dependencies: + unist-util-visit "^2.0.0" + +mdast-util-from-markdown@^0.8.0: + version "0.8.5" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz#d1ef2ca42bc377ecb0463a987910dae89bd9a28c" + integrity sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-string "^2.0.0" + micromark "~2.11.0" + parse-entities "^2.0.0" + unist-util-stringify-position "^2.0.0" + +mdast-util-to-hast@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-10.2.0.tgz#61875526a017d8857b71abc9333942700b2d3604" + integrity sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + mdast-util-definitions "^4.0.0" + mdurl "^1.0.0" + unist-builder "^2.0.0" + unist-util-generated "^1.0.0" + unist-util-position "^3.0.0" + unist-util-visit "^2.0.0" + +mdast-util-to-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz#b8cfe6a713e1091cb5b728fc48885a4767f8b97b" + integrity sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w== + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -8495,6 +8639,11 @@ mdn-data@2.0.6: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== +mdurl@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -8544,6 +8693,14 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= +micromark@~2.11.0: + version "2.11.4" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.4.tgz#d13436138eea826383e822449c9a5c50ee44665a" + integrity sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA== + dependencies: + debug "^4.0.0" + parse-entities "^2.0.0" + micromatch@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" @@ -9215,6 +9372,18 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-entities@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" + integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ== + dependencies: + character-entities "^1.0.0" + character-entities-legacy "^1.0.0" + character-reference-invalid "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.0" + is-hexadecimal "^1.0.0" + parse-json@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.0.1.tgz#7cfe35c1ccd641bce3981467e6c2ece61b3b3878" @@ -10104,6 +10273,13 @@ prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-information@^5.3.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" + integrity sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA== + dependencies: + xtend "^4.0.0" + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -10776,11 +10952,40 @@ regjsparser@^0.8.2: dependencies: jsesc "~0.5.0" +rehype-react@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/rehype-react/-/rehype-react-6.2.1.tgz#9b9bf188451ad6f63796b784fe1f51165c67b73a" + integrity sha512-f9KIrjktvLvmbGc7si25HepocOg4z0MuNOtweigKzBcDjiGSTGhyz6VSgaV5K421Cq1O+z4/oxRJ5G9owo0KVg== + dependencies: + "@mapbox/hast-util-table-cell-style" "^0.2.0" + hast-to-hyperscript "^9.0.0" + +rehype-sanitize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/rehype-sanitize/-/rehype-sanitize-4.0.0.tgz#b5241cf66bcedc49cd4e924a5f7a252f00a151ad" + integrity sha512-ZCr/iQRr4JeqPjun5i9CHHILVY7i45VnLu1CkkibDrSyFQ7dTLSvw8OIQpHhS4RSh9h/9GidxFw1bRb0LOxIag== + dependencies: + hast-util-sanitize "^3.0.0" + relateurl@^0.2.7: version "0.2.7" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= +remark-parse@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-9.0.0.tgz#4d20a299665880e4f4af5d90b7c7b8a935853640" + integrity sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw== + dependencies: + mdast-util-from-markdown "^0.8.0" + +remark-rehype@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-8.1.0.tgz#610509a043484c1e697437fa5eb3fd992617c945" + integrity sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA== + dependencies: + mdast-util-to-hast "^10.2.0" + renderkid@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" @@ -11388,6 +11593,11 @@ sourcemap-codec@^1.4.4: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +space-separated-tokens@^1.0.0: + version "1.1.5" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" + integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA== + spawn-command@^0.0.2-1: version "0.0.2-1" resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" @@ -11725,6 +11935,13 @@ style-loader@^3.3.1: resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.1.tgz#057dfa6b3d4d7c7064462830f9113ed417d38575" integrity sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ== +style-to-object@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.3.0.tgz#b1b790d205991cc783801967214979ee19a76e46" + integrity sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA== + dependencies: + inline-style-parser "0.1.1" + stylehacks@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.0.tgz#a40066490ca0caca04e96c6b02153ddc39913520" @@ -12038,6 +12255,11 @@ trim-right@^1.0.1: resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= +trough@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" + integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== + tryer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" @@ -12230,6 +12452,18 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== +unified@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975" + integrity sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ== + dependencies: + bail "^1.0.0" + extend "^3.0.0" + is-buffer "^2.0.0" + is-plain-obj "^2.0.0" + trough "^1.0.0" + vfile "^4.0.0" + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" @@ -12242,6 +12476,69 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" +unist-builder@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-2.0.3.tgz#77648711b5d86af0942f334397a33c5e91516436" + integrity sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw== + +unist-util-generated@^1.0.0: + version "1.1.6" + resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-1.1.6.tgz#5ab51f689e2992a472beb1b35f2ce7ff2f324d4b" + integrity sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg== + +unist-util-is@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-3.0.0.tgz#d9e84381c2468e82629e4a5be9d7d05a2dd324cd" + integrity sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A== + +unist-util-is@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.1.0.tgz#976e5f462a7a5de73d94b706bac1b90671b57797" + integrity sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg== + +unist-util-position@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-3.1.0.tgz#1c42ee6301f8d52f47d14f62bbdb796571fa2d47" + integrity sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA== + +unist-util-stringify-position@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz#cce3bfa1cdf85ba7375d1d5b17bdc4cada9bd9da" + integrity sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g== + dependencies: + "@types/unist" "^2.0.2" + +unist-util-visit-parents@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-2.1.2.tgz#25e43e55312166f3348cae6743588781d112c1e9" + integrity sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g== + dependencies: + unist-util-is "^3.0.0" + +unist-util-visit-parents@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz#65a6ce698f78a6b0f56aa0e88f13801886cdaef6" + integrity sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^4.0.0" + +unist-util-visit@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3" + integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw== + dependencies: + unist-util-visit-parents "^2.0.0" + +unist-util-visit@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c" + integrity sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^4.0.0" + unist-util-visit-parents "^3.0.0" + universalify@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -12384,6 +12681,24 @@ vary@^1, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= +vfile-message@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a" + integrity sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ== + dependencies: + "@types/unist" "^2.0.0" + unist-util-stringify-position "^2.0.0" + +vfile@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.1.tgz#03f1dce28fc625c625bc6514350fbdb00fa9e624" + integrity sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA== + dependencies: + "@types/unist" "^2.0.0" + is-buffer "^2.0.0" + unist-util-stringify-position "^2.0.0" + vfile-message "^2.0.0" + w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -12427,6 +12742,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-namespaces@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" + integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== + webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -12865,7 +13185,7 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.2: +xtend@^4.0.0, xtend@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== From a93ff380a279c4e6a04a329003d5f5c391703e50 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Jun 2022 11:48:51 +0300 Subject: [PATCH 02/99] Fix too early fetch of loadData calls (fetch app-wide assets first) --- server/dataLoader.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/server/dataLoader.js b/server/dataLoader.js index 65595753f..1f2687eb2 100644 --- a/server/dataLoader.js +++ b/server/dataLoader.js @@ -9,13 +9,14 @@ exports.loadData = function(requestUrl, sdk, appInfo) { let translations = {}; const store = configureStore({}, sdk); - const dataLoadingCalls = matchedRoutes.reduce((calls, match) => { - const { route, params } = match; - if (typeof route.loadData === 'function' && !route.auth) { - calls.push(store.dispatch(route.loadData(params, query))); - } - return calls; - }, []); + const dataLoadingCalls = () => + matchedRoutes.reduce((calls, match) => { + const { route, params } = match; + if (typeof route.loadData === 'function' && !route.auth) { + calls.push(store.dispatch(route.loadData(params, query))); + } + return calls; + }, []); // First fetch app-wide assets // Then make loadData calls @@ -23,9 +24,9 @@ exports.loadData = function(requestUrl, sdk, appInfo) { // This order supports other asset (in the future) that should be fetched before data calls. return store .dispatch(fetchAppAssets(config.appCdnAssets)) - .then(fetchedAssets => { - translations = fetchedAssets?.translations?.data || {}; - return Promise.all(dataLoadingCalls); + .then(fetchedAppAssets => { + translations = fetchedAppAssets?.translations?.data || {}; + return Promise.all(dataLoadingCalls()); }) .then(() => { return { preloadedState: store.getState(), translations }; From 09e10a0637f32e082cbe24cabb415702720dd4b3 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Jun 2022 11:51:48 +0300 Subject: [PATCH 03/99] Don't log loadData calls on test environment --- src/Routes.js | 6 ++++-- src/app.js | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Routes.js b/src/Routes.js index ae4f6e1b0..9d106bd8e 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -26,8 +26,10 @@ const callLoadData = props => { if (shouldLoadData) { dispatch(loadData(match.params, location.search)) .then(() => { - // eslint-disable-next-line no-console - console.log(`loadData success for ${name} route`); + if (props.logLoadDataCalls) { + // This gives good input for debugging issues on live environments, but with test it's not needed. + console.log(`loadData success for ${name} route`); + } }) .catch(e => { log.error(e, 'load-data-failed', { routeName: name }); diff --git a/src/app.js b/src/app.js index 9067811d7..ca94bbdab 100644 --- a/src/app.js +++ b/src/app.js @@ -92,6 +92,8 @@ const setupLocale = () => { export const ClientApp = props => { const { store, hostedTranslations = {} } = props; setupLocale(); + // This gives good input for debugging issues on live environments, but with test it's not needed. + const logLoadDataCalls = config?.env !== 'test'; return ( { - + From 34a37a19372218fc16dcc8a22f0d9e7e63d2be90 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Jun 2022 11:53:41 +0300 Subject: [PATCH 04/99] Add AspectRatioWrapper component (already used in FTW-product) --- .../AspectRatioWrapper/AspectRatioWrapper.js | 37 +++++++++++++++++++ .../AspectRatioWrapper.module.css | 21 +++++++++++ src/components/index.js | 1 + 3 files changed, 59 insertions(+) create mode 100644 src/components/AspectRatioWrapper/AspectRatioWrapper.js create mode 100644 src/components/AspectRatioWrapper/AspectRatioWrapper.module.css diff --git a/src/components/AspectRatioWrapper/AspectRatioWrapper.js b/src/components/AspectRatioWrapper/AspectRatioWrapper.js new file mode 100644 index 000000000..22bc15b7c --- /dev/null +++ b/src/components/AspectRatioWrapper/AspectRatioWrapper.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { node, number, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './AspectRatioWrapper.module.css'; + +const AspectRatioWrapper = props => { + const { children, className, rootClassName, width, height, ...rest } = props; + const classes = classNames(rootClassName || css.root, className); + + const aspectRatio = (height / width) * 100; + const paddingBottom = `${aspectRatio}%`; + + return ( +
+
+
{children}
+
+
+ ); +}; + +AspectRatioWrapper.defaultProps = { + className: null, + rootClassName: null, + children: null, +}; + +AspectRatioWrapper.propTypes = { + className: string, + rootClassName: string, + width: number.isRequired, + height: number.isRequired, + children: node, +}; + +export default AspectRatioWrapper; diff --git a/src/components/AspectRatioWrapper/AspectRatioWrapper.module.css b/src/components/AspectRatioWrapper/AspectRatioWrapper.module.css new file mode 100644 index 000000000..f9795c681 --- /dev/null +++ b/src/components/AspectRatioWrapper/AspectRatioWrapper.module.css @@ -0,0 +1,21 @@ +.root { + /* Layout */ + display: block; + width: 100%; + position: relative; +} + +/* Firefox doesn't support image aspect ratio inside flexbox */ +/* Aspect ratio for is given inline */ +.aspectPadding { +} + +.aspectBox { + /* Layout - image will take space defined by aspect ratio wrapper */ + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 100%; +} diff --git a/src/components/index.js b/src/components/index.js index 93975a2c2..9ebe5bf7e 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -34,6 +34,7 @@ export { default as IconSpinner } from './IconSpinner/IconSpinner'; export { default as IconSuccess } from './IconSuccess/IconSuccess'; // Other independent components +export { default as AspectRatioWrapper } from './AspectRatioWrapper/AspectRatioWrapper'; export { default as ExternalLink } from './ExternalLink/ExternalLink'; export { default as ExpandingTextarea } from './ExpandingTextarea/ExpandingTextarea'; export { default as Form } from './Form/Form'; From c8b4c4edb67e8565b97698456aeb8550d1607305 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 10 Jun 2022 21:30:28 +0300 Subject: [PATCH 05/99] Sanitize URL (essentially, Braintree's sanitizer) --- src/util/sanitize.js | 49 ++++++++++ src/util/sanitize.test.js | 182 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/util/sanitize.test.js diff --git a/src/util/sanitize.js b/src/util/sanitize.js index 163f07a61..f90eb3330 100644 --- a/src/util/sanitize.js +++ b/src/util/sanitize.js @@ -24,6 +24,55 @@ const sanitizeText = str => ? str.replace(ESCAPE_TEXT_REGEXP, ch => ESCAPE_TEXT_REPLACEMENTS[ch]) : ''; +// URL sanitizer. This code is adapted from +// https://github.com/braintree/sanitize-url/ +// +const INVALID_PROTOCOL_REGEXP = /^([^\w]*)(javascript|data|vbscript)/im; +const HTML_ENTITIES_REGEXP = /&#(\w+)(^\w|;)?/g; +const CTRL_CHARACTERS_REGEXP = /[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim; +const URL_SCHEME_REGEXP = /^([^:]+):/gm; +const RELATIVE_FIRST_CHARACTERS = ['.', '/']; + +function isRelativeUrlWithoutProtocol(url) { + return RELATIVE_FIRST_CHARACTERS.indexOf(url[0]) > -1; +} + +// adapted from https://stackoverflow.com/a/29824550/2601552 +function decodeHtmlCharacters(str) { + return str.replace(HTML_ENTITIES_REGEXP, (match, dec) => { + return String.fromCharCode(dec); + }); +} + +export function sanitizeUrl(url) { + const sanitizedUrl = decodeHtmlCharacters(url || '') + .replace(CTRL_CHARACTERS_REGEXP, '') + .trim(); + + if (!sanitizedUrl) { + return 'about:blank'; + } + + if (isRelativeUrlWithoutProtocol(sanitizedUrl)) { + return sanitizedUrl; + } + + const urlSchemeParseResults = sanitizedUrl.match(URL_SCHEME_REGEXP); + + if (!urlSchemeParseResults) { + return sanitizedUrl; + } + + const urlScheme = urlSchemeParseResults[0]; + + if (INVALID_PROTOCOL_REGEXP.test(urlScheme)) { + return 'about:blank'; + } + + return sanitizedUrl; +} +// + /** * Sanitize user entity. * If you add public data, you should probably sanitize it here. diff --git a/src/util/sanitize.test.js b/src/util/sanitize.test.js new file mode 100644 index 000000000..a0fa6c4b2 --- /dev/null +++ b/src/util/sanitize.test.js @@ -0,0 +1,182 @@ +import { sanitizeUrl } from './sanitize'; + +describe('sanitize utils', () => { + // Originates to https://github.com/braintree/sanitize-url/ + describe('sanitizeUrl', () => { + it('does not alter http URLs with alphanumeric characters', () => { + expect(sanitizeUrl('http://example.com/path/to:something')).toBe( + 'http://example.com/path/to:something' + ); + }); + + it('does not alter http URLs with ports with alphanumeric characters', () => { + expect(sanitizeUrl('http://example.com:4567/path/to:something')).toBe( + 'http://example.com:4567/path/to:something' + ); + }); + + it('does not alter https URLs with alphanumeric characters', () => { + expect(sanitizeUrl('https://example.com')).toBe('https://example.com'); + }); + + it('does not alter https URLs with ports with alphanumeric characters', () => { + expect(sanitizeUrl('https://example.com:4567/path/to:something')).toBe( + 'https://example.com:4567/path/to:something' + ); + }); + + it('does not alter relative-path reference URLs with alphanumeric characters', () => { + expect(sanitizeUrl('./path/to/my.json')).toBe('./path/to/my.json'); + }); + + it('does not alter absolute-path reference URLs with alphanumeric characters', () => { + expect(sanitizeUrl('/path/to/my.json')).toBe('/path/to/my.json'); + }); + + it('does not alter protocol-less network-path URLs with alphanumeric characters', () => { + expect(sanitizeUrl('//google.com/robots.txt')).toBe('//google.com/robots.txt'); + }); + + it('does not alter protocol-less URLs with alphanumeric characters', () => { + expect(sanitizeUrl('www.example.com')).toBe('www.example.com'); + }); + + it('does not alter deep-link urls with alphanumeric characters', () => { + // Customized test + expect(sanitizeUrl('com.blaa.demo://example')).toBe('com.blaa.demo://example'); + }); + + it('does not alter mailto urls with alphanumeric characters', () => { + expect(sanitizeUrl('mailto:test@example.com?subject=hello+world')).toBe( + 'mailto:test@example.com?subject=hello+world' + ); + }); + + it('does not alter urls with accented characters', () => { + expect(sanitizeUrl('www.example.com/with-áccêntš')).toBe('www.example.com/with-áccêntš'); + }); + + it('does not strip harmless unicode characters', () => { + expect(sanitizeUrl('www.example.com/лот.рфшишкиü–')).toBe('www.example.com/лот.рфшишкиü–'); + }); + + it('strips out ctrl chars', () => { + expect(sanitizeUrl('www.example.com/\u200D\u0000\u001F\x00\x1F\uFEFFfoo')).toBe( + 'www.example.com/foo' + ); + }); + + it('replaces blank urls with about:blank', () => { + expect(sanitizeUrl('')).toBe('about:blank'); + }); + + it('replaces null values with about:blank', () => { + expect(sanitizeUrl(null)).toBe('about:blank'); + }); + + it('replaces undefined values with about:blank', () => { + expect(sanitizeUrl()).toBe('about:blank'); + }); + + it('removes whitespace from urls', () => { + expect(sanitizeUrl(' http://example.com/path/to:something ')).toBe( + 'http://example.com/path/to:something' + ); + }); + + it('decodes html entities', () => { + // all these decode to javascript:alert('xss'); + const attackVectors = [ + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + "jav ascript:alert('XSS');", + "  javascript:alert('XSS');", + ]; + + attackVectors.forEach(vector => { + expect(sanitizeUrl(vector)).toBe('about:blank'); + }); + + // https://example.com/javascript:alert('XSS') + // since the javascript is the url path, and not the protocol, + // this url is technically sanitized + expect( + sanitizeUrl( + 'https://example.com/javascript:alert('XSS')' + ) + ).toBe("https://example.com/javascript:alert('XSS')"); + }); + + describe('invalid protocols', () => { + describe.each(['javascript', 'data', 'vbscript'])('%s', protocol => { + it(`replaces ${protocol} urls with about:blank`, () => { + expect(sanitizeUrl(`${protocol}:alert(document.domain)`)).toBe('about:blank'); + }); + + it(`allows ${protocol} urls that start with a letter prefix`, () => { + expect(sanitizeUrl(`not_${protocol}:alert(document.domain)`)).toBe( + `not_${protocol}:alert(document.domain)` + ); + }); + + it(`disallows ${protocol} urls that start with non-\w characters as a suffix for the protocol`, () => { + expect(sanitizeUrl(`&!*${protocol}:alert(document.domain)`)).toBe('about:blank'); + }); + + it(`disregards capitalization for ${protocol} urls`, () => { + // upper case every other letter in protocol name + const mixedCapitalizationProtocol = protocol + .split('') + .map((character, index) => { + if (index % 2 === 0) { + return character.toUpperCase(); + } + return character; + }) + .join(''); + + expect(sanitizeUrl(`${mixedCapitalizationProtocol}:alert(document.domain)`)).toBe( + 'about:blank' + ); + }); + + it(`ignores invisible ctrl characters in ${protocol} urls`, () => { + const protocolWithControlCharacters = protocol + .split('') + .map((character, index) => { + if (index === 1) { + return character + '%EF%BB%BF%EF%BB%BF'; + } else if (index === 2) { + return character + '%e2%80%8b'; + } + return character; + }) + .join(''); + + expect( + sanitizeUrl( + decodeURIComponent(`${protocolWithControlCharacters}:alert(document.domain)`) + ) + ).toBe('about:blank'); + }); + + it(`replaces ${protocol} urls with about:blank when url begins with %20`, () => { + expect( + sanitizeUrl(decodeURIComponent(`%20%20%20%20${protocol}:alert(document.domain)`)) + ).toBe('about:blank'); + }); + + it(`replaces ${protocol} urls with about:blank when ${protocol} url begins with spaces`, () => { + expect(sanitizeUrl(` ${protocol}:alert(document.domain)`)).toBe('about:blank'); + }); + + it(`does not replace ${protocol}: if it is not in the scheme of the URL`, () => { + expect(sanitizeUrl(`http://example.com#${protocol}:foo`)).toBe( + `http://example.com#${protocol}:foo` + ); + }); + }); + }); + }); +}); From 58312514c527375e5413e6307d1f17a83f9f1189 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 13 Jul 2022 16:21:30 +0300 Subject: [PATCH 06/99] Add primitive components --- .../ResponsiveImage/ResponsiveImage.js | 4 +- .../PageBuilder/Primitives/Code/Code.js | 44 ++++++ .../Primitives/Code/Code.module.css | 29 ++++ .../PageBuilder/Primitives/Code/index.js | 1 + .../PageBuilder/Primitives/Heading/Heading.js | 87 +++++++++++ .../Primitives/Heading/Heading.module.css | 29 ++++ .../PageBuilder/Primitives/Heading/index.js | 1 + .../PageBuilder/Primitives/Image/Image.js | 137 ++++++++++++++++++ .../Primitives/Image/Image.module.css | 18 +++ .../PageBuilder/Primitives/Image/index.js | 1 + .../PageBuilder/Primitives/Ingress/Ingress.js | 24 +++ .../Primitives/Ingress/Ingress.module.css | 7 + .../PageBuilder/Primitives/Ingress/index.js | 1 + .../PageBuilder/Primitives/Link/Link.js | 49 +++++++ .../Primitives/Link/Link.module.css | 3 + .../PageBuilder/Primitives/Link/index.js | 1 + .../PageBuilder/Primitives/List/List.js | 46 ++++++ .../Primitives/List/List.module.css | 27 ++++ .../PageBuilder/Primitives/List/index.js | 1 + src/containers/PageBuilder/Primitives/P/P.js | 25 ++++ .../PageBuilder/Primitives/P/P.module.css | 11 ++ .../PageBuilder/Primitives/P/index.js | 1 + .../PageBuilder/Primitives/README.md | 27 ++++ src/util/types.js | 15 ++ 24 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 src/containers/PageBuilder/Primitives/Code/Code.js create mode 100644 src/containers/PageBuilder/Primitives/Code/Code.module.css create mode 100644 src/containers/PageBuilder/Primitives/Code/index.js create mode 100644 src/containers/PageBuilder/Primitives/Heading/Heading.js create mode 100644 src/containers/PageBuilder/Primitives/Heading/Heading.module.css create mode 100644 src/containers/PageBuilder/Primitives/Heading/index.js create mode 100644 src/containers/PageBuilder/Primitives/Image/Image.js create mode 100644 src/containers/PageBuilder/Primitives/Image/Image.module.css create mode 100644 src/containers/PageBuilder/Primitives/Image/index.js create mode 100644 src/containers/PageBuilder/Primitives/Ingress/Ingress.js create mode 100644 src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css create mode 100644 src/containers/PageBuilder/Primitives/Ingress/index.js create mode 100644 src/containers/PageBuilder/Primitives/Link/Link.js create mode 100644 src/containers/PageBuilder/Primitives/Link/Link.module.css create mode 100644 src/containers/PageBuilder/Primitives/Link/index.js create mode 100644 src/containers/PageBuilder/Primitives/List/List.js create mode 100644 src/containers/PageBuilder/Primitives/List/List.module.css create mode 100644 src/containers/PageBuilder/Primitives/List/index.js create mode 100644 src/containers/PageBuilder/Primitives/P/P.js create mode 100644 src/containers/PageBuilder/Primitives/P/P.module.css create mode 100644 src/containers/PageBuilder/Primitives/P/index.js create mode 100644 src/containers/PageBuilder/Primitives/README.md diff --git a/src/components/ResponsiveImage/ResponsiveImage.js b/src/components/ResponsiveImage/ResponsiveImage.js index e2429b818..a70bd1cab 100644 --- a/src/components/ResponsiveImage/ResponsiveImage.js +++ b/src/components/ResponsiveImage/ResponsiveImage.js @@ -34,7 +34,7 @@ */ import React from 'react'; -import { arrayOf, string } from 'prop-types'; +import { arrayOf, oneOfType, string } from 'prop-types'; import classNames from 'classnames'; import { FormattedMessage } from '../../util/reactIntl'; import { propTypes } from '../../util/types'; @@ -95,7 +95,7 @@ ResponsiveImage.propTypes = { className: string, rootClassName: string, alt: string.isRequired, - image: propTypes.image, + image: oneOfType([propTypes.image, propTypes.imageAsset]), variants: arrayOf(string).isRequired, noImageMessage: string, }; diff --git a/src/containers/PageBuilder/Primitives/Code/Code.js b/src/containers/PageBuilder/Primitives/Code/Code.js new file mode 100644 index 000000000..cee23596b --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Code/Code.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { node, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './Code.module.css'; + +const defaultPropsCode = { + rootClassName: null, + className: null, +}; + +const propTypesCode = { + rootClassName: string, + className: string, + children: node.isRequired, +}; + +/** + * HTML element represents an inline code. + * It is marked in markdown with single backticks: some `inline code` + */ +export const Code = React.forwardRef((props, ref) => { + const { className, rootClassName, ...otherProps } = props; + const classes = classNames(rootClassName || css.code, className); + + return ; +}); +Code.displayName = 'Code'; +Code.defaultProps = defaultPropsCode; +Code.propTypes = propTypesCode; + +/** + * HTML element
 represents a preformatted text.
+ * Codeblock in markdown is rendered with 
 tag.
+ */
+export const CodeBlock = React.forwardRef((props, ref) => {
+  const { className, rootClassName, ...otherProps } = props;
+  const classes = classNames(rootClassName || css.codeBlock, className);
+
+  return 
;
+});
+CodeBlock.displayName = 'CodeBlock';
+CodeBlock.defaultProps = defaultPropsCode;
+CodeBlock.propTypes = propTypesCode;
diff --git a/src/containers/PageBuilder/Primitives/Code/Code.module.css b/src/containers/PageBuilder/Primitives/Code/Code.module.css
new file mode 100644
index 000000000..51abe9f4f
--- /dev/null
+++ b/src/containers/PageBuilder/Primitives/Code/Code.module.css
@@ -0,0 +1,29 @@
+.code {
+  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
+  font-size: 90%;
+  padding: 0.2rem 0.5rem;
+  margin: 0 0.2rem;
+  white-space: nowrap;
+  background-color: var(--matterColorNegative);
+  border: 1px solid var(--matterColorAnti);
+  border-radius: 4px;
+}
+
+.codeBlock {
+  display: block;
+  margin: 8px 0;
+  padding: 8px;
+  background-color: var(--matterColorNegative);
+  border: 1px solid var(--matterColorAnti);
+  border-radius: 4px;
+  overflow-x: auto;
+
+  & .code {
+    padding: 0;
+    margin: 0;
+    background-color: inherit;
+    border: 0;
+    border-radius: 0;
+    white-space: pre-wrap;
+  }
+}
diff --git a/src/containers/PageBuilder/Primitives/Code/index.js b/src/containers/PageBuilder/Primitives/Code/index.js
new file mode 100644
index 000000000..62cd2f96d
--- /dev/null
+++ b/src/containers/PageBuilder/Primitives/Code/index.js
@@ -0,0 +1 @@
+export { Code, CodeBlock } from './Code';
diff --git a/src/containers/PageBuilder/Primitives/Heading/Heading.js b/src/containers/PageBuilder/Primitives/Heading/Heading.js
new file mode 100644
index 000000000..fb6a4f176
--- /dev/null
+++ b/src/containers/PageBuilder/Primitives/Heading/Heading.js
@@ -0,0 +1,87 @@
+import React from 'react';
+import { node, string } from 'prop-types';
+import classNames from 'classnames';
+
+import css from './Heading.module.css';
+
+// Make it possible to use styling of H1, while the actual element is `

` +const Heading = props => { + const { className, rootClassName, as, tagRef, ...otherProps } = props; + const Tag = as || 'h2'; + const classes = classNames(rootClassName, className); + + return ; +}; + +const defaultPropsHeading = { + rootClassName: null, + className: null, + as: null, +}; + +const propTypesHeading = { + rootClassName: string, + className: string, + children: node.isRequired, + as: string, +}; + +export const H1 = React.forwardRef((props, ref) => { + const { rootClassName: rootClass, as, ...otherProps } = props; + return ( + + ); +}); +H1.displayName = 'H1'; +H1.defaultProps = defaultPropsHeading; +H1.propTypes = propTypesHeading; + +export const H2 = React.forwardRef((props, ref) => { + const { rootClassName: rootClass, as, ...otherProps } = props; + return ( + + ); +}); +H2.displayName = 'H2'; +H2.defaultProps = defaultPropsHeading; +H2.propTypes = propTypesHeading; + +export const H3 = React.forwardRef((props, ref) => { + const { rootClassName: rootClass, as, ...otherProps } = props; + return ( + + ); +}); +H3.displayName = 'H3'; +H3.defaultProps = defaultPropsHeading; +H3.propTypes = propTypesHeading; + +export const H4 = React.forwardRef((props, ref) => { + const { rootClassName: rootClass, as, ...otherProps } = props; + return ( + + ); +}); +H4.displayName = 'H4'; +H4.defaultProps = defaultPropsHeading; +H4.propTypes = propTypesHeading; + +export const H5 = React.forwardRef((props, ref) => { + const { rootClassName: rootClass, as, ...otherProps } = props; + return ( + + ); +}); +H5.displayName = 'H5'; +H5.defaultProps = defaultPropsHeading; +H5.propTypes = propTypesHeading; + +export const H6 = React.forwardRef((props, ref) => { + const { rootClassName: rootClass, as, ...otherProps } = props; + return ( + + ); +}); +H6.displayName = 'H6'; +H6.defaultProps = defaultPropsHeading; +H6.propTypes = propTypesHeading; diff --git a/src/containers/PageBuilder/Primitives/Heading/Heading.module.css b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css new file mode 100644 index 000000000..d9782268b --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css @@ -0,0 +1,29 @@ +/* Common styles */ +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + margin-top: 0; + line-height: 1.33; + font-weight: bold; +} + +/* Specific styles */ +.h1 { + font-size: 40px; +} + +.h2 { + font-size: 30px; + margin-bottom: 19px; +} + +.h3 { + font-size: 24px; +} + +.h4 { + font-size: 21px; +} diff --git a/src/containers/PageBuilder/Primitives/Heading/index.js b/src/containers/PageBuilder/Primitives/Heading/index.js new file mode 100644 index 000000000..0ba257127 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Heading/index.js @@ -0,0 +1 @@ +export { H1, H2, H3, H4, H5, H6 } from './Heading'; diff --git a/src/containers/PageBuilder/Primitives/Image/Image.js b/src/containers/PageBuilder/Primitives/Image/Image.js new file mode 100644 index 000000000..c3316318a --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Image/Image.js @@ -0,0 +1,137 @@ +import React from 'react'; +import { func, node, number, objectOf, oneOf, oneOfType, shape, string } from 'prop-types'; +import classNames from 'classnames'; + +import { types as sdkTypes } from '../../../../util/sdkLoader'; +import { AspectRatioWrapper, ResponsiveImage } from '../../../../components/index.js'; + +import css from './Image.module.css'; + +// Images in markdown point to elsewhere (they don't support responsive image variants) +export const MarkdownImage = React.forwardRef((props, ref) => { + const { className, rootClassName, ...otherProps } = props; + const classes = classNames(rootClassName || css.markdownImage, className); + + return ; +}); + +MarkdownImage.displayName = 'MarkdownImage'; + +MarkdownImage.defaultProps = { + rootClassName: null, + className: null, + alt: 'image', +}; + +MarkdownImage.propTypes = { + rootClassName: string, + className: string, + src: string.isRequired, + alt: string, +}; + +// BackgroundImage doesn't have enforcable aspectratio +export const BackgroundImage = React.forwardRef((props, ref) => { + const { className, rootClassName, alt, image, sizes, ...otherProps } = props; + + const { variants } = image?.attributes || {}; + const variantNames = Object.keys(variants); + + const classes = classNames(rootClassName || css.backgroundImage, className); + return ( + + ); +}); + +BackgroundImage.displayName = 'BackgroundImage'; + +BackgroundImage.defaultProps = { + rootClassName: null, + className: null, + alt: 'image', + sizes: null, +}; + +BackgroundImage.propTypes = { + rootClassName: string, + className: string, + alt: string, + image: shape({ + id: string.isRequired, + type: oneOf(['imageAsset']).isRequired, + attributes: shape({ + variants: objectOf( + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ).isRequired, + }).isRequired, + }).isRequired, + sizes: string, +}; + +// Image as a Field (by default these are only allowed inside a block). +export const FieldImage = React.forwardRef((props, ref) => { + const { className, rootClassName, alt, image, sizes, ...otherProps } = props; + + const { variants } = image?.attributes || {}; + const variantNames = Object.keys(variants); + + // We assume aspect ratio from the first image variant + const firstImageVariant = variants[variantNames[0]]; + const { width: aspectWidth, height: aspectHeight } = firstImageVariant || {}; + + const classes = classNames(rootClassName || css.markdownImage, className); + return ( + + + + ); +}); + +FieldImage.displayName = 'FieldImage'; + +FieldImage.defaultProps = { + rootClassName: null, + className: null, + alt: 'image', + sizes: null, +}; + +FieldImage.propTypes = { + rootClassName: string, + className: string, + alt: string, + image: shape({ + id: string.isRequired, + type: oneOf(['imageAsset']).isRequired, + attributes: shape({ + variants: objectOf( + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ).isRequired, + }).isRequired, + }).isRequired, + sizes: string, +}; diff --git a/src/containers/PageBuilder/Primitives/Image/Image.module.css b/src/containers/PageBuilder/Primitives/Image/Image.module.css new file mode 100644 index 000000000..ba7bb772f --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Image/Image.module.css @@ -0,0 +1,18 @@ +.markdownImage { + width: 100%; + border-radius: 8px; + object-fit: cover; +} + +.backgroundImage { + object-fit: cover; + width: 100%; + height: 100%; +} + +.fieldImage { + width: 100%; + height: 100%; + border-radius: 8px; + object-fit: cover; +} diff --git a/src/containers/PageBuilder/Primitives/Image/index.js b/src/containers/PageBuilder/Primitives/Image/index.js new file mode 100644 index 000000000..c5862f68e --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Image/index.js @@ -0,0 +1 @@ +export { MarkdownImage, BackgroundImage, FieldImage } from './Image'; diff --git a/src/containers/PageBuilder/Primitives/Ingress/Ingress.js b/src/containers/PageBuilder/Primitives/Ingress/Ingress.js new file mode 100644 index 000000000..0e90600c2 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Ingress/Ingress.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { node, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './Ingress.module.css'; + +export const Ingress = React.forwardRef((props, ref) => { + const { className, rootClassName, ...otherProps } = props; + const classes = classNames(rootClassName || css.ingress, className); + + return

; +}); + +Ingress.displayName = 'Ingress'; +Ingress.defaultProps = { + rootClassName: null, + className: null, +}; + +Ingress.propTypes = { + rootClassName: string, + className: string, + children: node.isRequired, +}; diff --git a/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css new file mode 100644 index 000000000..836ab841e --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css @@ -0,0 +1,7 @@ +.ingress { + font-size: 18px; + line-height: 1.66; + margin-top: 0; + margin-bottom: 24px; + letter-spacing: 0; +} diff --git a/src/containers/PageBuilder/Primitives/Ingress/index.js b/src/containers/PageBuilder/Primitives/Ingress/index.js new file mode 100644 index 000000000..a901bae8a --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Ingress/index.js @@ -0,0 +1 @@ +export { Ingress } from './Ingress'; diff --git a/src/containers/PageBuilder/Primitives/Link/Link.js b/src/containers/PageBuilder/Primitives/Link/Link.js new file mode 100644 index 000000000..e9e5561f3 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Link/Link.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { node, string } from 'prop-types'; +import classNames from 'classnames'; + +import routeConfiguration from '../../../../routeConfiguration.js'; +import { matchPathname } from '../../../../util/routes.js'; + +import { NamedLink, ExternalLink } from '../../../../components/index.js'; +import css from './Link.module.css'; + +export const Link = React.forwardRef((props, ref) => { + const { className, rootClassName, href, children } = props; + const classes = classNames(rootClassName || css.link, className); + const linkProps = { className: classes, href, children }; + + // Markdown parser (rehype-sanitize) might return undefined href + if (!href || !children) { + return null; + } + + if (href.charAt(0) === '/') { + // Internal link + const matchedRoutes = matchPathname(href, routeConfiguration()); + if (matchedRoutes.length > 0) { + const found = matchedRoutes[0]; + const testURL = new URL('http://my.marketplace.com' + href); + const to = { search: testURL.search, hash: testURL.hash }; + return ( + + ); + } + } + + return ; +}); + +Link.displayName = 'Link'; + +Link.defaultProps = { + rootClassName: null, + className: null, +}; + +Link.propTypes = { + rootClassName: string, + className: string, + children: node.isRequired, + href: string.isRequired, +}; diff --git a/src/containers/PageBuilder/Primitives/Link/Link.module.css b/src/containers/PageBuilder/Primitives/Link/Link.module.css new file mode 100644 index 000000000..a7d2375dd --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Link/Link.module.css @@ -0,0 +1,3 @@ +.link { + color: var(--marketplaceColor); +} diff --git a/src/containers/PageBuilder/Primitives/Link/index.js b/src/containers/PageBuilder/Primitives/Link/index.js new file mode 100644 index 000000000..61fe08c6f --- /dev/null +++ b/src/containers/PageBuilder/Primitives/Link/index.js @@ -0,0 +1 @@ +export { Link } from './Link'; diff --git a/src/containers/PageBuilder/Primitives/List/List.js b/src/containers/PageBuilder/Primitives/List/List.js new file mode 100644 index 000000000..f338e4fef --- /dev/null +++ b/src/containers/PageBuilder/Primitives/List/List.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { node, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './List.module.css'; + +const defaultPropsList = { + rootClassName: null, + className: null, +}; + +const propTypesList = { + rootClassName: string, + className: string, + children: node.isRequired, +}; + +export const Ul = React.forwardRef((props, ref) => { + const { className, rootClassName, ...otherProps } = props; + const classes = classNames(rootClassName || css.ul, className); + + return

    ; +}); +Ul.displayName = 'Ul'; +Ul.defaultProps = defaultPropsList; +Ul.propTypes = propTypesList; + +export const Ol = React.forwardRef((props, ref) => { + const { className, rootClassName, ...otherProps } = props; + const classes = classNames(rootClassName || css.ol, className); + + return
      ; +}); +Ol.displayName = 'Ol'; +Ol.defaultProps = defaultPropsList; +Ol.propTypes = propTypesList; + +export const Li = React.forwardRef((props, ref) => { + const { className, rootClassName, ...otherProps } = props; + const classes = classNames(rootClassName || css.li, className); + + return
    1. ; +}); +Li.displayName = 'Li'; +Li.defaultProps = defaultPropsList; +Li.propTypes = propTypesList; diff --git a/src/containers/PageBuilder/Primitives/List/List.module.css b/src/containers/PageBuilder/Primitives/List/List.module.css new file mode 100644 index 000000000..05744e21e --- /dev/null +++ b/src/containers/PageBuilder/Primitives/List/List.module.css @@ -0,0 +1,27 @@ +.ul { + display: block; + list-style-type: disc; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + padding-inline-start: 40px; + + & .ul { + list-style-type: circle; + + & .ul { + list-style-type: square; + } + } +} + +.ol { +} + +.li { +} + +.li > p { + margin: 0; +} diff --git a/src/containers/PageBuilder/Primitives/List/index.js b/src/containers/PageBuilder/Primitives/List/index.js new file mode 100644 index 000000000..1a7ca15a3 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/List/index.js @@ -0,0 +1 @@ +export { Ul, Ol, Li } from './List'; diff --git a/src/containers/PageBuilder/Primitives/P/P.js b/src/containers/PageBuilder/Primitives/P/P.js new file mode 100644 index 000000000..bb22870de --- /dev/null +++ b/src/containers/PageBuilder/Primitives/P/P.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { node, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './P.module.css'; + +export const P = React.forwardRef((props, ref) => { + const { className, rootClassName, ...otherProps } = props; + const classes = classNames(rootClassName || css.p, className); + + return

      ; +}); + +P.displayName = 'P'; + +P.defaultProps = { + rootClassName: null, + className: null, +}; + +P.propTypes = { + rootClassName: string, + className: string, + children: node.isRequired, +}; diff --git a/src/containers/PageBuilder/Primitives/P/P.module.css b/src/containers/PageBuilder/Primitives/P/P.module.css new file mode 100644 index 000000000..9973bae63 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/P/P.module.css @@ -0,0 +1,11 @@ +.p { + max-width: 80ch; + line-height: 1.66; + margin-top: 0; + margin-bottom: 24px; + letter-spacing: 0; +} + +h2 + p { + margin-top: 16px; +} diff --git a/src/containers/PageBuilder/Primitives/P/index.js b/src/containers/PageBuilder/Primitives/P/index.js new file mode 100644 index 000000000..fe6a8f3db --- /dev/null +++ b/src/containers/PageBuilder/Primitives/P/index.js @@ -0,0 +1 @@ +export { P } from './P'; diff --git a/src/containers/PageBuilder/Primitives/README.md b/src/containers/PageBuilder/Primitives/README.md new file mode 100644 index 000000000..3f9bdd145 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/README.md @@ -0,0 +1,27 @@ +# Primitive UI components + +These components are actually used to render field data. Fields are pieces of content defined in a +page asset JSON file. They are usually part of data grouped under Section or Block content. For +example, field data could look like this: + +```json +"title": { + "type": "heading1", + "content": "Hello World" +} +``` + +The **_title_** field above should therefore be rendered with the H1 heading component: + +```js +import { H1 } from './Primitives/Heading'; +``` + +Most of the primitives are just simple wrappers for built-in React elements / DOM tags. It makes it +easy to style and add extra features to similar components without changing all the places that +actually use that component. + +For example, Heading components have an optional prop: **as**. This is a concept that's quite often +used by CSS-in-JS libraries. You can use it to decouple an actual HTML element and its style to fix +SEO vs UI issues. So, `

      Hello World

      ` would render `

      ` DOM element with styles +that match `H1` component. diff --git a/src/util/types.js b/src/util/types.js index 30427cf50..12c056c8d 100644 --- a/src/util/types.js +++ b/src/util/types.js @@ -90,6 +90,21 @@ propTypes.image = shape({ }), }); +// ImageAsset type from Asset Delivery API +propTypes.imageAsset = shape({ + id: string.isRequired, + type: propTypes.value('imageAsset').isRequired, + attributes: shape({ + variants: objectOf( + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ), + }), +}); + // Denormalised user object propTypes.currentUser = shape({ id: propTypes.uuid.isRequired, From 2ca5d51084d6435841ed8cd973419e7478c6f84c Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 14 Jul 2022 13:44:03 +0300 Subject: [PATCH 07/99] Add Field component --- .../PageBuilder/Field/Field.helpers.js | 105 +++++++++ .../PageBuilder/Field/Field.helpers.test.js | 170 ++++++++++++++ src/containers/PageBuilder/Field/Field.js | 210 ++++++++++++++++++ src/containers/PageBuilder/Field/README.md | 40 ++++ src/containers/PageBuilder/Field/index.js | 1 + 5 files changed, 526 insertions(+) create mode 100644 src/containers/PageBuilder/Field/Field.helpers.js create mode 100644 src/containers/PageBuilder/Field/Field.helpers.test.js create mode 100644 src/containers/PageBuilder/Field/Field.js create mode 100644 src/containers/PageBuilder/Field/README.md create mode 100644 src/containers/PageBuilder/Field/index.js diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js new file mode 100644 index 000000000..370c1306d --- /dev/null +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -0,0 +1,105 @@ +import { sanitizeUrl } from '../../../util/sanitize'; + +///////////////////////////// +// Pickers for valid props // +///////////////////////////// + +const hasContent = data => typeof data?.content === 'string'; + +/** + * Exposes "content" prop as children property, if "content" has type of string. + * + * @param {Object} data E.g. "{ type: 'heading3', content: 'my title' }" + * @returns object containing content string as value for key: children. + */ +export const exposeContentAsChildren = data => { + return hasContent(data) ? { children: data.content } : {}; +}; + +/** + * Exposes "content" property, if "content" has type of string. + * + * @param {Object} data E.g. "{ type: 'markdown', content: 'my title' }" + * @returns object containing "content" key if the value is string. + */ +export const exposeContentString = data => (hasContent(data) ? { content: data.content } : {}); + +/** + * Exposes "label" and "href" as "children" and "href" props respectively, + * if both are of type string. Exposed "href" is sanitized. + * + * @param {Object} data E.g. "{ type: 'link', label: 'my title', href: 'https://my.domain.com' }" + * @returns object containing children and href. + */ +export const exposeLinkProps = data => { + const { label, href } = data; + const hasCorrectProps = typeof label === 'string' && typeof href === 'string'; + // Sanitize the URL. See: src/utl/sanitize.js for more information. + const cleanUrl = hasCorrectProps ? sanitizeUrl(href) : null; + return cleanUrl ? { children: label, href: cleanUrl } : {}; +}; + +/** + * Exposes "alt" and image props. + * The "image" contains imageAsset entity, which has been denormalized at this point: + * { + * id: "", + * type: "imageAsset", + * attributes: { + * variants: { + * square: { + * url: "https://something.imgix.com/foo/bar/baz", + * width: 1200, + * height: 580, + * }, + * square2x: { + * url: "https://something.imgix.com/foo/bar/baz", + * width: 2400, + * height: 1160, + * }, + * }, + * }, + * } + * + * @param {Object} data E.g. "{ type: 'image', alt: 'my portrait', image: { id, type, attributes } }" + * @returns object containing alt string and variants. + */ +export const exposeImageProps = data => { + const { alt, image } = data; + const { id, type, attributes } = image || {}; + + if (type !== 'imageAsset') { + return {}; + } + + const variantEntries = Object.entries(image?.attributes?.variants || {}); + const variants = variantEntries.reduce((validVariants, entry) => { + const [key, value] = entry; + const { url, width, height } = value || {}; + + const isValid = typeof width === 'number' && typeof height === 'number'; + return isValid + ? { + ...validVariants, + [key]: { url: sanitizeUrl(url), width, height }, + } + : validVariants; + }, {}); + + const isValidImage = typeof data?.alt === 'string' && Object.keys(variants).length > 0; + const sanitizedImage = { id, type, attributes: { ...attributes, variants } }; + return isValidImage ? { alt, image: sanitizedImage } : {}; +}; + +/** + * Exposes "color" property, if it contains hexadecimal string like "#FF0000" or "#F00". + * + * @param {Object} data E.g. "{ type: 'hexColor', color: '#FFFFFF' }" + * @returns object containing color prop. + */ +export const exposeColorProps = data => { + const color = data?.color; + const re = new RegExp('^#([0-9a-f]{3}){1,2}$', 'i'); + const isValidColor = typeof color === 'string' && re.test(color); + return isValidColor ? { color } : {}; +}; diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js new file mode 100644 index 000000000..a03eb563c --- /dev/null +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -0,0 +1,170 @@ +import { + exposeContentAsChildren, + exposeContentString, + exposeLinkProps, + exposeImageProps, + exposeColorProps, +} from './Field.helpers'; + +describe('Field helpers', () => { + describe('exposeContentAsChildren(data)', () => { + it('should return only "children" prop containing the string from passed-in "content"', () => { + expect(exposeContentAsChildren({ content: 'Hello world!' })).toEqual({ + children: 'Hello world!', + }); + expect(exposeContentAsChildren({ content: 'Hello world!', blaa: 'blaa' })).toEqual({ + children: 'Hello world!', + }); + }); + + it('should return empty object if "content" is not string', () => { + expect(exposeContentAsChildren({ content: ['Hello world!'] })).toEqual({}); + expect(exposeContentAsChildren({ content: {} })).toEqual({}); + }); + }); + + describe('exposeContentString(data)', () => { + it('should return only "children" prop containing the string from passed-in "content"', () => { + expect(exposeContentString({ content: 'Hello world!' })).toEqual({ content: 'Hello world!' }); + expect(exposeContentString({ content: 'Hello world!', blaa: 'blaa' })).toEqual({ + content: 'Hello world!', + }); + }); + + it('should return empty object if "content" is not string', () => { + expect(exposeContentString({ content: ['Hello world!'] })).toEqual({}); + expect(exposeContentString({ content: {} })).toEqual({}); + }); + }); + + describe('exposeLinkProps(data)', () => { + it('should return only "label" and "href" props containing valid strings"', () => { + expect( + exposeLinkProps({ label: 'Hello world!', href: 'https://my.example.com/some/image.png' }) + ).toEqual({ children: 'Hello world!', href: 'https://my.example.com/some/image.png' }); + expect( + exposeLinkProps({ + label: 'Hello world!', + href: 'https://my.example.com/some/image.png', + blaa: 'blaa', + }) + ).toEqual({ children: 'Hello world!', href: 'https://my.example.com/some/image.png' }); + }); + it('should return empty object if data is not valid', () => { + expect(exposeLinkProps({ label: 'Hello world!', blaa: 'blaa' })).toEqual({}); + expect(exposeLinkProps({ label: 0, href: 'https://my.example.com/some/image.png' })).toEqual( + {} + ); + }); + it('should return "about:blank" in href if url in data is not valid', () => { + expect( + exposeLinkProps({ label: 'Hello world!', href: "jav ascript:alert('XSS');" }) + ).toEqual({ children: 'Hello world!', href: 'about:blank' }); + }); + }); + + describe('exposeImageProps(data)', () => { + it('should return only "alt" and "variants" props', () => { + const image = { + id: 'image-id', + type: 'imageAsset', + attributes: { + variants: { + square: { + url: 'https://something.imgix.com/foo/bar/baz', + width: 1200, + height: 580, + }, + square2x: { + url: 'https://something.imgix.com/foo/bar/baz', + width: 2400, + height: 1160, + }, + }, + }, + }; + + expect(exposeImageProps({ alt: 'Hello world!', image })).toEqual({ + alt: 'Hello world!', + image, + }); + expect(exposeImageProps({ alt: 'Hello world!', image, blaa: 'blaa' })).toEqual({ + alt: 'Hello world!', + image, + }); + }); + + it('should return empty object if data is not valid', () => { + const image = { + id: 'image-id', + type: 'imageAsset', + attributes: { + variants: { + square: { + url: 'https://something.imgix.com/foo/bar/baz', + width: 1200, + height: 580, + }, + }, + }, + }; + expect(exposeLinkProps({ alt: 'Hello world!', blaa: 'blaa' })).toEqual({}); + expect(exposeLinkProps({ alt: 0, image })).toEqual({}); + expect(exposeLinkProps({ alt: 'Hello world!', image: {} })).toEqual({}); + }); + + it('should return "about:blank" in href if url in data is not valid', () => { + const image = { + id: 'image-id', + type: 'imageAsset', + attributes: { + variants: { + square: { + url: "jav ascript:alert('XSS');", + width: 1200, + height: 580, + }, + }, + }, + }; + + const expected = { + id: 'image-id', + type: 'imageAsset', + attributes: { + variants: { + square: { + url: 'about:blank', + width: 1200, + height: 580, + }, + }, + }, + }; + + expect(exposeImageProps({ alt: 'Hello world!', image })).toEqual({ + alt: 'Hello world!', + image: expected, + }); + }); + }); + + describe('exposeColorProps(data)', () => { + it('should return only "color" prop containing valid hexadecimal color code', () => { + expect(exposeColorProps({ color: '#FFAA00' })).toEqual({ color: '#FFAA00' }); + expect(exposeColorProps({ color: '#FA0' })).toEqual({ color: '#FA0' }); + expect(exposeColorProps({ color: '#000000' })).toEqual({ color: '#000000' }); + }); + it('should return empty "color" prop if invalid hexadecimal color code was detected', () => { + expect(exposeColorProps({ color: '#FFAA0000' })).toEqual({}); + expect(exposeColorProps({ color: 'FA0' })).toEqual({}); + expect(exposeColorProps({ color: '000000' })).toEqual({}); + expect(exposeColorProps({ color: '#XX0000' })).toEqual({}); + expect(exposeColorProps({ color: '#FFAA0' })).toEqual({}); + expect(exposeColorProps({ color: 'rgb(100, 100, 100)' })).toEqual({}); + expect(exposeColorProps({ color: 'hsl(60 100% 50%)' })).toEqual({}); + expect(exposeColorProps({ color: 'hwb(90 10% 10%)' })).toEqual({}); + expect(exposeColorProps({ color: 'tomato' })).toEqual({}); + }); + }); +}); diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js new file mode 100644 index 000000000..77e3afa7c --- /dev/null +++ b/src/containers/PageBuilder/Field/Field.js @@ -0,0 +1,210 @@ +import React from 'react'; +import { exact, func, node, number, objectOf, oneOf, oneOfType, shape, string } from 'prop-types'; + +// Primitive components that are actually used for rendering field data +// These are essentially calling the index.js +// E.g. import { H1, H2, H3, H4, H5, H6 } from '../Primitives/Heading/index.js'; +import { H1, H2, H3, H4, H5, H6 } from '../Primitives/Heading'; +import { Ul, Ol, Li } from '../Primitives/List'; +import { Ingress } from '../Primitives/Ingress'; +import { P } from '../Primitives/P'; +import { Code, CodeBlock } from '../Primitives/Code'; +import { Link } from '../Primitives/Link'; +import { MarkdownImage, BackgroundImage, FieldImage } from '../Primitives/Image'; + +import renderMarkdown from '../markdownProcessor'; + +import { + exposeContentAsChildren, + exposeContentString, + exposeLinkProps, + exposeImageProps, + exposeColorProps, +} from './Field.helpers'; + +//////////////////////// +// Markdown component // +//////////////////////// + +// Most fields are primitives but markdown is a bit special case. +// It gets its own "components" mapping that it uses to render the markdown content +const MarkdownField = ({ content, components }) => renderMarkdown(content, components); + +/////////////////////////////////////////// +// Mapping of field types and components // +/////////////////////////////////////////// + +const defaultFieldComponents = { + heading1: { component: H1, pickValidProps: exposeContentAsChildren }, + heading2: { component: H2, pickValidProps: exposeContentAsChildren }, + heading3: { component: H3, pickValidProps: exposeContentAsChildren }, + heading4: { component: H4, pickValidProps: exposeContentAsChildren }, + heading5: { component: H5, pickValidProps: exposeContentAsChildren }, + heading6: { component: H6, pickValidProps: exposeContentAsChildren }, + paragraph: { component: Ingress, pickValidProps: exposeContentAsChildren }, + externalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, + internalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, + image: { component: FieldImage, pickValidProps: exposeImageProps }, + backgroundImage: { component: BackgroundImage, pickValidProps: exposeImageProps }, + + // markdown content field is pretty complex component + markdown: { + component: MarkdownField, + pickValidProps: exposeContentString, + options: { + // Custom components mapped to be rendered for markdown content (instead of the default ones) + components: { + ul: Ul, + ol: Ol, + li: Li, + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + h6: H6, + p: P, + img: MarkdownImage, + code: Code, + pre: CodeBlock, + a: Link, + }, + }, + }, + // hexColor doesn't render component: it's used as an inlined background-color for section component + hexColor: { pickValidProps: exposeColorProps }, +}; + +////////////////// +// Props picker // +////////////////// + +const getFieldConfig = (data, defaultFieldComponents, options) => { + const customFieldComponents = options?.fieldComponents || {}; + const fieldMapping = { ...defaultFieldComponents, ...customFieldComponents }; + return fieldMapping[(data?.type)]; +}; + +// This is useful for fields that are not used as components (e.g. background-color) +export const validProps = (data, options) => { + if (!data || Object.keys(data).length === 0) { + // If there's no data, the (optional) field in Console has been left untouched. + return null; + } + + const config = getFieldConfig(data, defaultFieldComponents, options); + const pickValidProps = config?.pickValidProps; + if (data && pickValidProps) { + const validProps = pickValidProps(data); + + // If picker returns an empty object, data was invalid. + // Field will render null, but we should warn the dev that data was not valid. + if (Object.keys(validProps).length === 0) { + console.warn(`Invalid props detected. Data: ${JSON.stringify(data)}`); + } + return validProps; + } + + if (data && !config) { + // If there's no config, the field type is unknown => the app can't know what to render + console.warn(`Unknown field type (${data?.type}) detected. Data: ${JSON.stringify(data)}`); + } else if (data && !pickValidProps) { + console.warn(`There's no validator (pickValidProps) for this field type (${data?.type}).`); + } + return null; +}; + +//////////////////// +// Field selector // +//////////////////// + +// Generic field component that picks a specific UI component based on 'type' +const Field = props => { + const { data, options: fieldOptions, ...propsFromParent } = props; + + // Check the data and pick valid props only + const validPropsFromData = validProps(data, fieldOptions); + const hasValidProps = validPropsFromData && Object.keys(validPropsFromData).length > 0; + + // Config contains component, pickValidProps, and potentially also options. + // E.g. markdown has options.components to override default elements + const config = getFieldConfig(data, defaultFieldComponents, fieldOptions); + const { component: Component, options = {} } = config || {}; + + // Render the correct field component + if (Component && hasValidProps) { + return ; + } + + return null; +}; + +// Field's prop types: +const propTypeTextContent = shape({ + type: oneOf([ + 'heading1', + 'heading2', + 'heading3', + 'heading4', + 'heading5', + 'heading6', + 'ingress', + 'paragraph', + 'markdown', + ]).isRequired, + content: string.isRequired, +}); +const propTypeColor = shape({ + type: oneOf(['hexColor']).isRequired, + color: string.isRequired, + href: string.isRequired, +}); +const propTypeLink = shape({ + type: oneOf(['externalButtonLink', 'internalButtonLink']).isRequired, + label: string.isRequired, + href: string.isRequired, +}); +const propTypeImageAsset = shape({ + type: oneOf(['image', 'backgroundImage']).isRequired, + alt: string.isRequired, + image: shape({ + id: string.isRequired, + type: oneOf(['imageAsset']).isRequired, + attributes: shape({ + variants: objectOf( + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ).isRequired, + }).isRequired, + }).isRequired, +}); + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), +}); + +// Empty objects might be received through page data asset for optional fields. +// If you get a warning "Failed prop type: Invalid prop `data` supplied to `Field`." +// on localhost environment. +// This is the catch for those invalid data fields that don't have known "type". +const propTypeEmptyObject = exact({}); + +Field.defaultProps = { + options: null, +}; + +Field.propTypes = { + data: oneOfType([ + propTypeTextContent, + propTypeColor, + propTypeLink, + propTypeImageAsset, + propTypeEmptyObject, + ]), + options: propTypeOption, +}; + +export default Field; diff --git a/src/containers/PageBuilder/Field/README.md b/src/containers/PageBuilder/Field/README.md new file mode 100644 index 000000000..1bc85e514 --- /dev/null +++ b/src/containers/PageBuilder/Field/README.md @@ -0,0 +1,40 @@ +# Fields + +Fields are pieces of content defined in the page asset JSON file. They are usually part of data +grouped under Section or Block content. For example, field data could look like this: + +```json +"title": { + "type": "heading1", + "content": "Hello World" +} +``` + +The Field component will check the type of the field and validate the data. If there are valid data +(e.g. if "content" is valid data for type "heading1"), the field renders the content. + +## Mapping of field types and components + +The mapping of content type vs component & pickValidProps, could look like this: + +```js +const defaultFieldComponents = { + heading1: { component: H1, pickValidProps: exposeContentAsChildren }, + paragraph: { component: Ingress, pickValidProps: exposeContentAsChildren }, + externalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, + image: { component: FieldImage, pickValidProps: exposeImageProps }, + // In some cases, the data is used without a renderable component. + // Data for "background-color" in _style_ prop could be an example of that. + hexColor: { pickValidProps: exposeColorProps }, +}; +``` + +It's also possible to pass additional mapping to the Field component: + +```jsx + +``` + +## Rendable components + +The Field uses components from the _Primitives_ directory to actually render the data it receives. diff --git a/src/containers/PageBuilder/Field/index.js b/src/containers/PageBuilder/Field/index.js new file mode 100644 index 000000000..983df2cf3 --- /dev/null +++ b/src/containers/PageBuilder/Field/index.js @@ -0,0 +1 @@ +export { default, validProps } from './Field'; From 96151bda176da0f6f50e2914e3dcb065e8ce394d Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 14 Jul 2022 14:58:25 +0300 Subject: [PATCH 08/99] Add BlockBuilder component --- .../PageBuilder/BlockBuilder/BlockBuilder.js | 88 ++++++++++++++++++ .../BlockContainer/BlockContainer.js | 30 ++++++ .../BlockContainer/BlockContainer.module.css | 3 + .../BlockBuilder/BlockContainer/index.js | 2 + .../BlockBuilder/BlockDefault/BlockDefault.js | 85 +++++++++++++++++ .../BlockDefault/BlockDefault.module.css | 13 +++ .../BlockBuilder/BlockDefault/index.js | 2 + .../PageBuilder/BlockBuilder/README.md | 91 +++++++++++++++++++ .../PageBuilder/BlockBuilder/index.js | 12 +++ 9 files changed, 326 insertions(+) create mode 100644 src/containers/PageBuilder/BlockBuilder/BlockBuilder.js create mode 100644 src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.js create mode 100644 src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css create mode 100644 src/containers/PageBuilder/BlockBuilder/BlockContainer/index.js create mode 100644 src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js create mode 100644 src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css create mode 100644 src/containers/PageBuilder/BlockBuilder/BlockDefault/index.js create mode 100644 src/containers/PageBuilder/BlockBuilder/README.md create mode 100644 src/containers/PageBuilder/BlockBuilder/index.js diff --git a/src/containers/PageBuilder/BlockBuilder/BlockBuilder.js b/src/containers/PageBuilder/BlockBuilder/BlockBuilder.js new file mode 100644 index 000000000..c9ccf414c --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/BlockBuilder.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { arrayOf, func, node, oneOf, shape, string } from 'prop-types'; + +// Block components +import BlockDefault from './BlockDefault'; + +/////////////////////////////////////////// +// Mapping of block types and components // +/////////////////////////////////////////// + +const defaultBlockComponents = { + ['default-block']: { component: BlockDefault }, +}; + +//////////////////// +// Blocks builder // +//////////////////// + +const BlockBuilder = props => { + const { blocks, options, ...otherProps } = props; + + // Extract block & field component mappings from props + // If external mapping has been included for fields + // E.g. { h1: { component: MyAwesomeHeader } } + const { blockComponents, fieldComponents } = options || {}; + const blockOptionsMaybe = fieldComponents ? { options: { fieldComponents } } : {}; + + // If there's no block, we can't render the correct block component + if (!blocks || blocks.length === 0) { + return null; + } + + // Selection of Block components + // Combine component-mapping from props together with the default one: + const components = { ...defaultBlockComponents, ...blockComponents }; + + return ( + <> + {blocks.map(block => { + const config = components[block.blockType]; + const Block = config?.component; + if (Block) { + return ; + } else { + // If the block type is unknown, the app can't know what to render + console.warn(`Unknown block type (${block.blockType}) detected.`); + return null; + } + })} + + ); +}; + +const propTypeBlock = shape({ + blockId: string.isRequired, + blockType: oneOf(['default-block']).isRequired, + // Plus all kind of unknown fields. + // BlockBuilder doesn't really need to care about those +}); + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), + blockComponents: shape({ component: node }), +}); + +BlockBuilder.defaultProps = { + blocks: [], + options: null, + responsiveImageSizes: null, + className: null, + rootClassName: null, + mediaClassName: null, + textClassName: null, + ctaButtonClass: null, +}; + +BlockBuilder.propTypes = { + blocks: arrayOf(propTypeBlock), + options: propTypeOption, + responsiveImageSizes: string, + className: string, + rootClassName: string, + mediaClassName: string, + textClassName: string, + ctaButtonClass: string, +}; + +export default BlockBuilder; diff --git a/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.js b/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.js new file mode 100644 index 000000000..1c4f5b470 --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './BlockContainer.module.css'; + +// This element can be used to wrap some common styles and features, +// if there are multiple blockTypes, +const BlockContainer = props => { + const { className, rootClassName, as, ...otherProps } = props; + const Tag = as || 'div'; + const classes = classNames(rootClassName || css.root, className); + + // Note: otherProps contains "children" too! + return ; +}; + +BlockContainer.defaultProps = { + rootClassName: null, + className: null, + as: 'div', +}; + +BlockContainer.propTypes = { + rootClassName: string, + className: string, + as: string, +}; + +export default BlockContainer; diff --git a/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css b/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css new file mode 100644 index 000000000..1b421ac56 --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css @@ -0,0 +1,3 @@ +.root { + padding-bottom: calc(32px * 2); +} diff --git a/src/containers/PageBuilder/BlockBuilder/BlockContainer/index.js b/src/containers/PageBuilder/BlockBuilder/BlockContainer/index.js new file mode 100644 index 000000000..896d390ed --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/BlockContainer/index.js @@ -0,0 +1,2 @@ +import BlockContainer from './BlockContainer'; +export default BlockContainer; diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js new file mode 100644 index 000000000..28e5e6ffd --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { func, node, object, shape, string } from 'prop-types'; +import classNames from 'classnames'; + +import Field from '../../Field'; +import BlockContainer from '../BlockContainer'; + +import css from './BlockDefault.module.css'; + +const FieldMedia = props => { + const { className, media, sizes, options } = props; + return media ? ( +
      + +
      + ) : null; +}; + +const BlockDefault = props => { + const { + blockId, + className, + rootClassName, + mediaClassName, + textClassName, + ctaButtonClass, + title, + text, + callToAction, + media, + responsiveImageSizes, + options, + } = props; + const classes = classNames(rootClassName || css.root, className); + return ( + + +
      + + + +
      +
      + ); +}; + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), +}); + +BlockDefault.defaultProps = { + className: null, + rootClassName: null, + mediaClassName: null, + textClassName: null, + ctaButtonClass: null, + title: null, + text: null, + callToAction: null, + media: null, + responsiveImageSizes: null, + options: null, +}; + +BlockDefault.propTypes = { + blockId: string.isRequired, + className: string, + rootClassName: string, + mediaClassName: string, + textClassName: string, + ctaButtonClass: string, + title: object, + text: object, + callToAction: object, + media: object, + responsiveImageSizes: string, + options: propTypeOption, +}; + +export default BlockDefault; diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css new file mode 100644 index 000000000..e0c984208 --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css @@ -0,0 +1,13 @@ +.root { +} + +.media { + width: 100%; + background-color: var(--matterColorNegative); /* Loading state color for the images */ + border-radius: 8px; + margin-bottom: 24px; +} + +.text { + width: 100%; +} diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/index.js b/src/containers/PageBuilder/BlockBuilder/BlockDefault/index.js new file mode 100644 index 000000000..644ebfe3a --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/index.js @@ -0,0 +1,2 @@ +import BlockDefault from './BlockDefault'; +export default BlockDefault; diff --git a/src/containers/PageBuilder/BlockBuilder/README.md b/src/containers/PageBuilder/BlockBuilder/README.md new file mode 100644 index 000000000..5d7e9a6d8 --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/README.md @@ -0,0 +1,91 @@ +## BlockBuilder + +The default schema for page content has 3 levels that can include content fields: + +- **page asset** (data from Asset Delivery API) + - **sections** (page asset contains sections) + - **blocks** (section might contain blocks) + +This is the builder for block types. Although, at the time of writing, there's only one block type +supported: '**default-block**'. The component called **BlockDefault** handles the rendering of that +block type. + +```jsx + +``` + +## Adding a new block component + +1. Create a new folder + + - E.g. _BlockMyComponent_ + - The naming convention (i.e. _'Block'_ prefix) is just there to help to work with code editors + and text suggestions. + +2. Add component files there + + - **_BlockMyComponent.js_** + - Main file containing the component's code + - There's a special component called BlockContainer, which should be used to wrap your + component. For example: + ```jsx + + + + + ``` + - **_BlockMyComponent.module.css_** + - Styles for your component + - **_index.js_** + - This should just export your main component + +3. Edit _BlockBuilder.js_ + + 1. You need to import your component to BlockBuilder: + + ```js + // Block components + import BlockDefault from './BlockDefault'; + import BlockMyComponent from './BlockMyComponent'; + // This is the same as writing: + // import BlockMyComponent from './BlockMyComponent/index.js'; + ``` + + 2. Inside BlockBuilder, there's a mapping between block type and component that can render it: + + ```js + const defaultBlockComponents = { + ['default-block']: { component: BlockDefault }, + }; + ``` + + You can change this to use your custom component: + + ```js + const defaultBlockComponents = { + ['default-block']: { component: BlockMyComponent }, + }; + ``` diff --git a/src/containers/PageBuilder/BlockBuilder/index.js b/src/containers/PageBuilder/BlockBuilder/index.js new file mode 100644 index 000000000..3cfb78fb6 --- /dev/null +++ b/src/containers/PageBuilder/BlockBuilder/index.js @@ -0,0 +1,12 @@ +// Default wrapping element for block components +import BlockContainer from './BlockContainer'; + +// Block components +import BlockDefault from './BlockDefault'; + +// Main component: BlockBuilder +import BlockBuilder from './BlockBuilder'; + +export { BlockContainer, BlockDefault }; + +export default BlockBuilder; From ae419d83fa9ebd4967e4a9961949f644846e556f Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 14 Jul 2022 20:48:33 +0300 Subject: [PATCH 09/99] Add SectionBuilder component --- .../PageBuilder/SectionBuilder/README.md | 124 ++++ .../SectionArticle/SectionArticle.js | 105 +++ .../SectionArticle/SectionArticle.module.css | 8 + .../SectionBuilder/SectionArticle/index.js | 2 + .../SectionBuilder/SectionBuilder.example.js | 668 ++++++++++++++++++ .../SectionBuilder/SectionBuilder.js | 110 +++ .../SectionBuilder/SectionBuilder.module.css | 110 +++ .../SectionCarousel/SectionCarousel.js | 131 ++++ .../SectionCarousel.module.css | 80 +++ .../SectionBuilder/SectionCarousel/index.js | 2 + .../SectionColumns/SectionColumns.js | 129 ++++ .../SectionColumns/SectionColumns.module.css | 33 + .../SectionBuilder/SectionColumns/index.js | 2 + .../SectionContainer/SectionContainer.js | 64 ++ .../SectionContainer.module.css | 30 + .../SectionBuilder/SectionContainer/index.js | 2 + .../SectionFeatures/SectionFeatures.js | 114 +++ .../SectionFeatures.module.css | 33 + .../SectionBuilder/SectionFeatures/index.js | 2 + .../PageBuilder/SectionBuilder/index.js | 15 + src/styles/marketplaceDefaults.css | 4 +- 21 files changed, 1767 insertions(+), 1 deletion(-) create mode 100644 src/containers/PageBuilder/SectionBuilder/README.md create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionArticle/index.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionBuilder.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionCarousel/index.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionColumns/index.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.module.css create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionContainer/index.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css create mode 100644 src/containers/PageBuilder/SectionBuilder/SectionFeatures/index.js create mode 100644 src/containers/PageBuilder/SectionBuilder/index.js diff --git a/src/containers/PageBuilder/SectionBuilder/README.md b/src/containers/PageBuilder/SectionBuilder/README.md new file mode 100644 index 000000000..546a08f4f --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/README.md @@ -0,0 +1,124 @@ +## SectionBuilder + +The default schema for page content has 3 levels that can include content fields: + +- **page asset** (data from Asset Delivery API) + - **sections** (page asset contains sections) + - **blocks** (section might contain blocks) + +This is the builder for section types. For example, when encountering `sectionType: 'article'` +SectionBuilder uses internal component **SectionArticle** to render the section data. + +```jsx + +``` + +## Default section components + +The existing section components are created to be quite flexible they have many shared optional +fields. For example, you can give them a title, ingress, callToAction button, and blocks. So, these +sections are + +- **SectionArticle** + - Show article content in a bit narrower main column on desktop +- **SectionCarousel** + - Creates carousel effect from block content. +- **SectionColumns** + - Allows multiple columns to be shown on wider screens + - The number of columns is defined by **_numColumns_** prop + - Mobile layout shows all the blocks in a single column +- **SectionFeatures** + - Shows block content in a row mode: text and media are shown side by side + - Create alternating block flow: each row changes the order of text and media + +Each of these components uses **SectionContainer** to wrap the content. It can be used to include +some common styling to each section. For example, a responsive background image could be given to +this container. + +## Add a new section component + +1. Create a new directory + + - E.g. _SectionMyComponent_ + - The naming convention (i.e. _'Section'_ prefix) is just there to help working with code editors + and text suggestions. + +2. Add component files there + + - **_SectionMyComponent.js_** + - The main file containing the component's code + - There's a special component called SectionContainer, which should be used to wrap your + component. For example: + ```jsx + +
      + +
      +
      + ``` + - **_SectionMyComponent.module.css_** + - Styles for your component + - SectionBuilder also passes some shared styles like **_defaultClasses.title_** + - **_index.js_** + - This should just export your main component + +3. Edit _SectionBuilder.js_ + + 1. You need to import your component to BlockBuilder: + + ```js + // Section components + import SectionColumns from './SectionColumns'; + import SectionMyComponent from './SectionMyComponent'; + // This is the same as writing: + // import SectionMyComponent from './SectionMyComponent/index.js'; + ``` + + 2. Inside SectionBuilder, there's a mapping between section type and component that can render + it: + + ```js + const defaultSectionComponents = { + article: { component: SectionArticle }, + columns: { component: SectionColumns }, + // etc. + }; + ``` + + You can change this to use your custom component: + + ```js + const defaultSectionComponents = { + article: { component: SectionMyComponent }, + // etc. + }; + ``` diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js new file mode 100644 index 000000000..3988fd3d4 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { arrayOf, func, node, object, oneOf, shape, string } from 'prop-types'; + +import Field, { validProps } from '../../Field'; +import BlockBuilder from '../../BlockBuilder'; + +import SectionContainer from '../SectionContainer'; +import css from './SectionArticle.module.css'; + +// Section component that's able to show article content +// The article content is mainly supposed to be inside a block +const SectionArticle = props => { + const { + sectionId, + className, + rootClassName, + defaultClasses, + title, + ingress, + background, + backgroundImage, + callToAction, + blocks, + options, + } = props; + + // If external mapping has been included for fields + // E.g. { h1: { component: MyAwesomeHeader } } + const fieldComponents = options?.fieldComponents; + const fieldOptions = { fieldComponents }; + + // Find background color if it is included + const colorProp = validProps(background, fieldOptions); + const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + + const hasBlocks = blocks?.length > 0; + + return ( + +
      + + + +
      + {hasBlocks ? ( +
      + +
      + ) : null} +
      + ); +}; + +const propTypeBlock = shape({ + blockId: string.isRequired, + blockType: oneOf(['default-block']).isRequired, + // Plus all kind of unknown fields. + // Section doesn't really need to care about those +}); + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), +}); + +SectionArticle.defaultProps = { + className: null, + rootClassName: null, + defaultClasses: null, + textClassName: null, + title: null, + ingress: null, + background: null, + backgroundImage: null, + callToAction: null, + blocks: [], + options: null, +}; + +SectionArticle.propTypes = { + sectionId: string.isRequired, + className: string, + rootClassName: string, + defaultClasses: shape({ + sectionDetails: string, + title: string, + ingress: string, + ctaButton: string, + }), + title: object, + ingress: object, + background: object, + backgroundImage: object, + callToAction: object, + blocks: arrayOf(propTypeBlock), + options: propTypeOption, +}; + +export default SectionArticle; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css new file mode 100644 index 000000000..d98f1fd00 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css @@ -0,0 +1,8 @@ +.articleMain { + max-width: 720px; + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 32px; + margin: 0 auto; + padding: 32px; +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/index.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/index.js new file mode 100644 index 000000000..f89ab807a --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/index.js @@ -0,0 +1,2 @@ +import SectionArticle from './SectionArticle'; +export default SectionArticle; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js new file mode 100644 index 000000000..3053a07ee --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -0,0 +1,668 @@ +// Sections are currently defined inside SectionBuilder +import SectionBuilder from './SectionBuilder.js'; + +const hexYellow = '#FFAA00'; +const hexBlack = '#000000'; + +const imagePlaceholder = (width, height) => ({ + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/${width}/${height}`, // placeholderImage(width, height, '#00AAFF'), + width, + height, + }, + square2x: { + url: `https://picsum.photos/${2 * width}/${2 * height}`, // placeholderImage(2 * width, 2 * height, '#FF00AA'), + width: 2 * width, + height: 2 * width, + }, + }, + }, +}); + +///////////////////////////// +// SectionColumns examples // +///////////////////////////// + +export const SectionArticle = { + component: SectionBuilder, + props: { + sections: [ + { + sectionType: 'article', + sectionId: 'cms-article-section', + title: { + type: 'heading1', + content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', + }, + ingress: { + type: 'paragraph', + content: + 'Maecenas sed diam eget risus varius blandit sit amet non magna. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', + }, + callToAction: { + type: 'externalButtonLink', + href: '#', + label: 'Justo Tortor Amet', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-article-section-block-1', + media: { type: 'image', alt: 'Cute dog smiling', image: imagePlaceholder(600, 800) }, + title: { + type: 'heading2', + content: + 'Vestibulum id ligula porta felis euismod semper. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum.', + }, + text: { + type: 'markdown', + content: `Donec ullamcorper nulla non metus auctor fringilla. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Etiam porta sem malesuada magna mollis euismod. Maecenas sed diam eget risus varius blandit sit amet non magna. +Maecenas faucibus mollis interdum. Sed posuere consectetur est at lobortis. Etiam porta sem malesuada magna mollis euismod. Etiam porta sem malesuada magna mollis euismod. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Maecenas faucibus mollis interdum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id ligula porta felis euismod semper. Nullam id dolor id nibh ultricies vehicula ut id elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. + +Vestibulum id ligula porta felis euismod semper. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Donec id elit non mi porta gravida at eget metus. Nullam id dolor id nibh ultricies vehicula ut id elit. + +### Donec ullamcorper nulla non metus auctor fringilla. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Etiam porta sem malesuada magna mollis euismod. Maecenas sed diam eget risus varius blandit sit amet non magna. + +- Maecenas faucibus mollis interdum. +- Sed posuere consectetur est at lobortis. +- Etiam porta sem malesuada magna mollis euismod. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id ligula porta felis euismod semper. Nullam id dolor id nibh ultricies vehicula ut id elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. + +Vestibulum id ligula porta felis euismod semper. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Donec id elit non mi porta gravida at eget metus. Nullam id dolor id nibh ultricies vehicula ut id elit.`, + }, + callToAction: { + type: 'externalButtonLink', + href: 'https://www.sharetribe.com/academy/marketplace-funding/', + label: 'Read the article', + }, + }, + ], + }, + ], + }, + group: 'PageBuilder', +}; + +export const SectionFeatures = { + component: SectionBuilder, + props: { + sections: [ + { + sectionType: 'features', + sectionId: 'cms-features-section-no-block', + title: { + type: 'heading1', + content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', + }, + ingress: { + type: 'paragraph', + content: + 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + callToAction: { + type: 'externalButtonLink', + href: '#', + label: 'Justo Tortor Amet', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-features-block-1', + media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + title: { + type: 'heading2', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-features-block-2', + media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + title: { + type: 'heading2', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-features-block-3', + media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + title: { + type: 'heading2', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + ], + }, + ], + }, + group: 'PageBuilder', +}; + +export const SectionCarousel = { + component: SectionBuilder, + props: { + sections: [ + { + sectionType: 'carousel', + sectionId: 'cms-features-section-no-block', + numColumns: 1, + title: { + type: 'heading2', + content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', + }, + ingress: { + type: 'paragraph', + content: + 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + callToAction: { + type: 'externalButtonLink', + href: '#', + label: 'Justo Tortor Amet', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-carousel-block-1', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-2', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-3', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-4', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-5', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-6', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-7', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-8', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-carousel-block-9', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + ], + }, + ], + }, + group: 'PageBuilder', +}; + +export const SectionColumns = { + component: SectionBuilder, + props: { + sections: [ + { + sectionType: 'columns', + sectionId: 'cms-column-section-no-block', + numColumns: 1, + background: { type: 'hexColor', color: hexYellow }, + title: { type: 'heading2', content: 'One Column, No Blocks' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-no-block-dark', + numColumns: 1, + background: { type: 'hexColor', color: hexBlack }, + textColor: 'light', + title: { type: 'heading2', content: 'One Column, No Blocks' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + callToAction: { + type: 'externalButtonLink', + href: 'https://www.sharetribe.com/docs/', + label: 'Flex Docs', + }, + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-no-block-bg-img', + numColumns: 1, + background: { type: 'hexColor', color: hexYellow }, + backgroundImage: { + type: 'image', + alt: 'Background image', + image: imagePlaceholder(400, 400), + }, + title: { type: 'heading2', content: 'One Column, No Blocks, Bg Image' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + callToAction: { + type: 'externalButtonLink', + href: 'https://www.sharetribe.com/docs/', + label: 'Flex Docs', + }, + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-1', + numColumns: 1, + title: { type: 'heading2', content: 'One Column' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-column1-block-1', + title: { type: 'heading3', content: 'Block 1' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + }, + { + blockType: 'default-block', + blockId: 'cms-column1-block-2', + title: { type: 'heading3', content: 'Block 2' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-2', + numColumns: 2, + title: { type: 'heading2', content: '2 Columns' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-column2-block-1', + title: { type: 'heading3', content: 'Column 1' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + callToAction: { + type: 'internalButtonLink', + href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', + label: 'See the sauna', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-column2-block-2', + title: { type: 'heading3', content: 'Column 2' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + callToAction: { + type: 'internalButtonLink', + href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', + label: 'See the sauna', + }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-2-dark', + numColumns: 2, + background: { type: 'hexColor', color: hexBlack }, + textColor: 'light', + title: { type: 'heading2', content: '2 Columns, Dark' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-column2-block-1-dark', + title: { type: 'heading3', content: 'Column 1' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + callToAction: { + type: 'internalButtonLink', + href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', + label: 'See the sauna', + }, + }, + { + blockType: 'default-block', + blockId: 'cms-column2-block-2-dark', + title: { type: 'heading3', content: 'Column 2' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + callToAction: { + type: 'internalButtonLink', + href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', + label: 'See the sauna', + }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-3', + numColumns: 3, + title: { type: 'heading2', content: '3 Columns' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-column3-block-1', + media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + title: { type: 'heading3', content: 'Image 1' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column3-block-2', + media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, + title: { type: 'heading3', content: 'Image 2' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column3-block-3', + media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, + title: { type: 'heading3', content: 'Image 3' }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-4', + numColumns: 4, + title: { type: 'heading2', content: '4 Columns' }, + ingress: { + type: 'paragraph', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-column4-block-1-variant-1', + media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + title: { type: 'heading3', content: 'Image 1' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-2-variant-1', + media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, + title: { type: 'heading3', content: 'Image 2' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-3-variant-1', + media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, + title: { type: 'heading3', content: 'Image 3' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-4-variant-1', + media: { type: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 400) }, + title: { type: 'heading3', content: 'Image 4' }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-5', + numColumns: 4, + title: { type: 'heading2', content: '4 Columns 5 blocks' }, + ingress: { type: 'paragraph', content: 'Portrait images (400x500)' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-column4-block-1-variant-2', + media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 500) }, + title: { type: 'heading3', content: 'Image 1' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-2-variant-2', + media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 500) }, + title: { type: 'heading3', content: 'Image 2' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-3-variant-2', + media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 500) }, + title: { type: 'heading3', content: 'Image 3' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-4-variant-2', + media: { type: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 500) }, + title: { type: 'heading3', content: 'Image 4' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-5-variant-2', + media: { type: 'image', alt: 'Fifth image', image: imagePlaceholder(400, 500) }, + title: { type: 'heading3', content: 'Image 5' }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'cms-column-section-6', + numColumns: 4, + title: { type: 'heading2', content: '4 Columns 3 blocks' }, + ingress: { type: 'paragraph', content: 'Landscape images (400x300)' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-column4-block-1-variant-3', + media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 300) }, + title: { type: 'heading3', content: 'Image 1' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-2-variant-3', + media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 300) }, + title: { type: 'heading3', content: 'Image 2' }, + }, + { + blockType: 'default-block', + blockId: 'cms-column4-block-3-variant-3', + media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 300) }, + title: { type: 'heading3', content: 'Image 3' }, + }, + ], + }, + ], + }, + group: 'PageBuilder', +}; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js new file mode 100644 index 000000000..0629535e8 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -0,0 +1,110 @@ +import React from 'react'; +import { arrayOf, func, node, oneOf, shape, string } from 'prop-types'; +import classNames from 'classnames'; + +// Section components +import SectionArticle from './SectionArticle'; +import SectionCarousel from './SectionCarousel'; +import SectionColumns from './SectionColumns'; +import SectionFeatures from './SectionFeatures'; + +// Styles +// Note: these contain +// - shared classes that are passed as defaultClasses +// - dark theme overrides +// TODO: alternatively, we could consider more in-place way of theming components +import css from './SectionBuilder.module.css'; + +// These are shared classes. +// Use these to have consistent styles between different section components +// E.g. share the same title styles +const DEFAULT_CLASSES = { + sectionDetails: css.sectionDetails, + title: css.title, + ingress: css.ingress, + ctaButton: css.ctaButton, +}; + +///////////////////////////////////////////// +// Mapping of section types and components // +///////////////////////////////////////////// + +const defaultSectionComponents = { + article: { component: SectionArticle }, + carousel: { component: SectionCarousel }, + columns: { component: SectionColumns }, + features: { component: SectionFeatures }, +}; + +////////////////////// +// Section builder // +////////////////////// + +const SectionBuilder = props => { + const { sections, options } = props; + const { sectionComponents = {}, ...otherOption } = options || {}; + + // If there's no sections, we can't render the correct section component + if (!sections || sections.length === 0) { + return null; + } + + // Selection of Section components + const components = { ...defaultSectionComponents, ...sectionComponents }; + const getComponent = sectionType => { + const config = components[sectionType]; + return config?.component; + }; + + return ( + <> + {sections.map(section => { + const Section = getComponent(section.sectionType); + // If the default "dark" theme should be applied + const isDarkTheme = section.textColor === 'light'; + const classes = classNames({ [css.darkTheme]: isDarkTheme }); + + if (Section) { + return ( +
      + ); + } else { + // If the section type is unknown, the app can't know what to render + console.warn(`Unknown section type (${section.sectionType}) detected.`); + return null; + } + })} + + ); +}; + +const propTypeSection = shape({ + sectionId: string.isRequired, + sectionType: oneOf(['article', 'carousel', 'columns', 'features']).isRequired, + // Plus all kind of unknown fields. + // BlockBuilder doesn't really need to care about those +}); + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), + blockComponents: shape({ component: node }), + sectionComponents: shape({ component: node }), +}); + +SectionBuilder.defaultProps = { + sections: [], + options: null, +}; + +SectionBuilder.propTypes = { + sections: arrayOf(propTypeSection), + options: propTypeOption, +}; + +export default SectionBuilder; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css new file mode 100644 index 000000000..aede97d3b --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css @@ -0,0 +1,110 @@ +@import '../../../styles/customMediaQueries.css'; + +/* Styles for custom sections: classes shared by , , , etc. */ +.sectionDetails { + max-width: var(--contentMaxWidth); + display: grid; + justify-content: start; + margin: 0 auto; + padding: 0 32px 64px; + position: relative; + + @media (--viewportMedium) { + justify-content: center; + } +} + +.align { + text-align: left; + justify-self: start; + + @media (--viewportMedium) { + text-align: center; + justify-self: center; + } +} + +.title { + composes: align; + max-width: 30ch; +} + +.ingress { + composes: align; + max-width: 65ch; +} + +.ctaButton { + composes: align; + display: inline-block; + padding: 8px 20px; + font-size: 15px; + background-color: var(--marketplaceColor); + border-radius: 4px; + color: white; + text-decoration: none; + box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%); + font-weight: 500; + + &:hover { + text-decoration: none; + background-color: var(--marketplaceColorDark); + } +} + +/** + * Theme: dark + * These styles are at the bottom of the file so that they overwrite rules for default "light" theme. + */ +.darkTheme h1, +.darkTheme h2, +.darkTheme h3, +.darkTheme h4, +.darkTheme h5, +.darkTheme h6, +.darkTheme p, +.darkTheme li, +.darkTheme blockquote { + color: var(--matterColorLight); + + &::selection { + background-color: cyan; + color: unset; + } +} + +/* link on dark theme */ +.darkTheme a { + color: white; + text-decoration: underline; + + &:hover { + text-decoration: none; + color: var(--marketplaceColorLight); + } +} + +/* button on dark theme */ +.darkTheme .ctaButton { + border: 1px solid var(--marketplaceColorDark); + text-decoration: none; + + &:hover { + color: white; + } +} + +.darkTheme hr { + border-color: var(--matterColor); +} + +/* dark inline code */ +.darkTheme code { + background-color: var(--matterColor); + color: var(--matterColorNegative); +} + +/* dark code block */ +.darkTheme pre { + background-color: var(--matterColor); +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js new file mode 100644 index 000000000..d3c4970bc --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -0,0 +1,131 @@ +import React from 'react'; +import { arrayOf, func, node, number, object, oneOf, shape, string } from 'prop-types'; + +import Field, { validProps } from '../../Field'; +import BlockBuilder from '../../BlockBuilder'; + +import SectionContainer from '../SectionContainer'; +import css from './SectionCarousel.module.css'; + +// The number of columns (numColumns) affects styling and responsive images +const COLUMN_CONFIG = [ + { css: css.oneColumn, responsiveImageSizes: '(max-width: 767px) 100vw, 1200px' }, + { css: css.twoColumns, responsiveImageSizes: '(max-width: 767px) 100vw, 600px' }, + { css: css.threeColumns, responsiveImageSizes: '(max-width: 767px) 100vw, 400px' }, + { css: css.fourColumns, responsiveImageSizes: '(max-width: 767px) 100vw, 290px' }, +]; +const getIndex = numColumns => numColumns - 1; +const getColumnCSS = numColumns => { + const config = COLUMN_CONFIG[getIndex(numColumns)]; + return config ? config.css : COLUMN_CONFIG[0].css; +}; +const getResponsiveImageSizes = numColumns => { + const config = COLUMN_CONFIG[getIndex(numColumns)]; + return config ? config.responsiveImageSizes : COLUMN_CONFIG[0].responsiveImageSizes; +}; + +// Section component that's able to show blocks in a carousel +// the number blocks visible is defined by "numColumns" prop. +const SectionCarousel = props => { + const { + sectionId, + className, + rootClassName, + defaultClasses, + numColumns, + title, + ingress, + background, + backgroundImage, + callToAction, + blocks, + options, + } = props; + + // If external mapping has been included for fields + // E.g. { h1: { component: MyAwesomeHeader } } + const fieldComponents = options?.fieldComponents; + const fieldOptions = { fieldComponents }; + + // Find background color if it is included + const colorProp = validProps(background, fieldOptions); + const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + + const hasBlocks = blocks?.length > 0; + + return ( + +
      + + + +
      + {hasBlocks ? ( +
      + +
      + ) : null} +
      + ); +}; + +const propTypeBlock = shape({ + blockId: string.isRequired, + blockType: oneOf(['default-block']).isRequired, + // Plus all kind of unknown fields. + // Section doesn't really need to care about those +}); + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), +}); + +SectionCarousel.defaultProps = { + className: null, + rootClassName: null, + defaultClasses: null, + textClassName: null, + numColumns: 1, + title: null, + ingress: null, + background: null, + backgroundImage: null, + callToAction: null, + blocks: [], + options: null, +}; + +SectionCarousel.propTypes = { + sectionId: string.isRequired, + className: string, + rootClassName: string, + defaultClasses: shape({ + sectionDetails: string, + title: string, + ingress: string, + ctaButton: string, + }), + numColumns: number, + title: object, + ingress: object, + background: object, + backgroundImage: object, + callToAction: object, + blocks: arrayOf(propTypeBlock), + options: propTypeOption, +}; + +export default SectionCarousel; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css new file mode 100644 index 000000000..cb9dbc7f7 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -0,0 +1,80 @@ +@import '../../../../styles/customMediaQueries.css'; + +.baseCarousel { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; + scroll-snap-type: x mandatory; + + &::-webkit-scrollbar { + display: none; + } +} + +.oneColumn, +.twoColumns, +.threeColumns, +.fourColumns { + composes: baseCarousel; +} + +.block { + flex: 0 0 auto; + width: calc(100vw - 64px); /* 64px = horizontal layout paddings */ + margin-right: 16px; + scroll-snap-align: center; + + /* Offset the start of the carousel so it follows the global grid layout (1200 / 2 = 600px) */ + transform: translateX(calc(max(var(--contentMaxWidth), 100vw) / 2 - 600px)); + + &:last-of-type { + padding-right: 32px; + width: calc(100vw - 32px); /* 32px (padding-right above) */ + } +} + +.oneColumn .block { + max-width: calc(var(--contentMaxWidth) - 64px); /* 64px (horizontal layout paddings) */ + + &:last-of-type { + max-width: calc(var(--contentMaxWidth) - 32px); /* 32px (padding-right above) */ + } +} + +.twoColumns .block { + max-width: calc( + (var(--contentMaxWidth) - 64px - 18px) / 2 + ); /* 64px (horizontal layout paddings) - 18px (gutter) / 2 (number of columns) */ + + &:last-of-type { + max-width: calc( + (var(--contentMaxWidth) - 32px + 18px) / 2 + ); /* 32px (padding-right above) + 18px (gutter) / 2 (number of columns) */ + } +} + +.threeColumns .block { + max-width: calc( + (var(--contentMaxWidth) - 64px - 32px) / 3 + ); /* 64px (horizontal layout paddings) - 32px (two gutters á 18px) / 3 (number of columns) */ + + &:last-of-type { + max-width: calc( + (var(--contentMaxWidth) - 32px + 32px) / 3 + ); /* 32px (padding-right above) + 32px (two gutters á 18px) / 3 (number of columns) */ + } +} + +.fourColumns .block { + max-width: calc( + (var(--contentMaxWidth) - 64px - 54px) / 4 + ); /* 64px (horizontal layout paddings) - 54px (three gutters á 18px) / 4 (number of columns) */ + + &:last-of-type { + max-width: calc( + (var(--contentMaxWidth) - 32px + 54px) / 4 + ); /* 32px (padding-right above) + 54px (three gutters á 18px) / 4 (number of columns) */ + } +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/index.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/index.js new file mode 100644 index 000000000..6996d017e --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/index.js @@ -0,0 +1,2 @@ +import SectionCarousel from './SectionCarousel'; +export default SectionCarousel; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js new file mode 100644 index 000000000..7e62ec108 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js @@ -0,0 +1,129 @@ +import React from 'react'; +import { arrayOf, func, node, number, object, oneOf, shape, string } from 'prop-types'; + +import Field, { validProps } from '../../Field'; +import BlockBuilder from '../../BlockBuilder'; + +import SectionContainer from '../SectionContainer'; +import css from './SectionColumns.module.css'; + +// The number of columns (numColumns) affects styling and responsive images +const COLUMN_CONFIG = [ + { css: css.oneColumn, responsiveImageSizes: '(max-width: 767px) 100vw, 1200px' }, + { css: css.twoColumns, responsiveImageSizes: '(max-width: 767px) 100vw, 600px' }, + { css: css.threeColumns, responsiveImageSizes: '(max-width: 767px) 100vw, 400px' }, + { css: css.fourColumns, responsiveImageSizes: '(max-width: 767px) 100vw, 265px' }, +]; +const getIndex = numColumns => numColumns - 1; +const getColumnCSS = numColumns => { + const config = COLUMN_CONFIG[getIndex(numColumns)]; + return config ? config.css : COLUMN_CONFIG[0].css; +}; +const getResponsiveImageSizes = numColumns => { + const config = COLUMN_CONFIG[getIndex(numColumns)]; + return config ? config.responsiveImageSizes : COLUMN_CONFIG[0].responsiveImageSizes; +}; + +// Section component that's able to show blocks in multiple different columns (defined by "numColumns" prop) +const SectionColumns = props => { + const { + sectionId, + className, + rootClassName, + defaultClasses, + numColumns, + title, + ingress, + background, + backgroundImage, + callToAction, + blocks, + options, + } = props; + + // If external mapping has been included for fields + // E.g. { h1: { component: MyAwesomeHeader } } + const fieldComponents = options?.fieldComponents; + const fieldOptions = { fieldComponents }; + + // Find background color if it is included + const colorProp = validProps(background, fieldOptions); + const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + + const hasBlocks = blocks?.length > 0; + + return ( + +
      + + + +
      + {hasBlocks ? ( +
      + +
      + ) : null} +
      + ); +}; + +const propTypeBlock = shape({ + blockId: string.isRequired, + blockType: oneOf(['default-block']).isRequired, + // Plus all kind of unknown fields. + // Section doesn't really need to care about those +}); + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), +}); + +SectionColumns.defaultProps = { + className: null, + rootClassName: null, + defaultClasses: null, + textClassName: null, + numColumns: 1, + title: null, + ingress: null, + background: null, + backgroundImage: null, + callToAction: null, + blocks: [], + options: null, +}; + +SectionColumns.propTypes = { + sectionId: string.isRequired, + className: string, + rootClassName: string, + defaultClasses: shape({ + sectionDetails: string, + title: string, + ingress: string, + ctaButton: string, + }), + numColumns: number, + title: object, + ingress: object, + background: object, + backgroundImage: object, + callToAction: object, + blocks: arrayOf(propTypeBlock), + options: propTypeOption, +}; + +export default SectionColumns; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css new file mode 100644 index 000000000..d6be3955f --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css @@ -0,0 +1,33 @@ +@import '../../../../styles/customMediaQueries.css'; + +.baseColumn { + max-width: var(--contentMaxWidth); + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 32px; + margin: 0 auto; + padding: 32px; +} + +.oneColumn { + composes: baseColumn; +} + +.twoColumns { + composes: baseColumn; + @media (--viewportMedium) { + grid-template-columns: repeat(2, calc((100% - 32px) / 2)); + } +} +.threeColumns { + composes: baseColumn; + @media (--viewportMedium) { + grid-template-columns: repeat(3, calc((100% - 2 * 32px) / 3)); + } +} +.fourColumns { + composes: baseColumn; + @media (--viewportMedium) { + grid-template-columns: repeat(4, calc((100% - 3 * 32px) / 4)); + } +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/index.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/index.js new file mode 100644 index 000000000..9e07e569d --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/index.js @@ -0,0 +1,2 @@ +import SectionColumns from './SectionColumns'; +export default SectionColumns; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js new file mode 100644 index 000000000..a56955580 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { func, node, object, shape, string } from 'prop-types'; +import classNames from 'classnames'; + +import Field from '../../Field'; + +import css from './SectionContainer.module.css'; + +// Create Image field for background image +// This will be passed to SectionContainer as responsive "background" image +const BackgroundImageField = props => { + const { className, backgroundImage, options } = props; + return backgroundImage ? ( +
      + +
      + ) : null; +}; + +// This component can be used to wrap some common styles and features of Section-level components. +// E.g: const SectionHero = props => (

      Hello World!

      ); +const SectionContainer = props => { + const { className, rootClassName, as, children, backgroundImage, options, ...otherProps } = props; + const Tag = as || 'section'; + const classes = classNames(rootClassName || css.root, className); + + return ( + + +
      {children}
      +
      + ); +}; + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), +}); + +SectionContainer.defaultProps = { + rootClassName: null, + className: null, + as: 'div', + children: null, + backgroundImage: null, +}; + +SectionContainer.propTypes = { + rootClassName: string, + className: string, + as: string, + children: node, + backgroundImage: object, + options: propTypeOption, +}; + +export default SectionContainer; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.module.css b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.module.css new file mode 100644 index 000000000..55f8a2951 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.module.css @@ -0,0 +1,30 @@ +@import '../../../../styles/customMediaQueries.css'; + +.root { + background-size: cover; + background-position: center center; + background-repeat: no-repeat; + width: 100%; + position: relative; + + &:nth-of-type(odd) { + background-color: #fafafa; + } +} + +.sectionContent { + padding: 32px 0; + position: relative; + + @media (--viewportMedium) { + padding: 64px 0; + } +} + +.backgroundImageWrapper { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/index.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/index.js new file mode 100644 index 000000000..abedec426 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/index.js @@ -0,0 +1,2 @@ +import SectionContainer from './SectionContainer'; +export default SectionContainer; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js new file mode 100644 index 000000000..e83f2844e --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -0,0 +1,114 @@ +import React from 'react'; +import { arrayOf, func, node, object, oneOf, shape, string } from 'prop-types'; + +import Field, { validProps } from '../../Field'; +import BlockBuilder from '../../BlockBuilder'; + +import SectionContainer from '../SectionContainer'; + +import css from './SectionFeatures.module.css'; + +// Section component that shows features +// Blocks are shown in a row-like way: +// [image] text +// text [image] +// [image] text +const SectionFeatures = props => { + const { + sectionId, + className, + rootClassName, + defaultClasses, + title, + ingress, + background, + backgroundImage, + callToAction, + blocks, + options, + } = props; + + // If external mapping has been included for fields + // E.g. { h1: { component: MyAwesomeHeader } } + const fieldComponents = options?.fieldComponents; + const fieldOptions = { fieldComponents }; + + // Find background color if it is included + const colorProp = validProps(background, fieldOptions); + const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + + const hasBlocks = blocks?.length > 0; + + return ( + +
      + + + +
      + {hasBlocks ? ( +
      + +
      + ) : null} +
      + ); +}; + +const propTypeBlock = shape({ + blockId: string.isRequired, + blockType: oneOf(['default-block']).isRequired, + // Plus all kind of unknown fields. + // Section doesn't really need to care about those +}); + +const propTypeOption = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), +}); + +SectionFeatures.defaultProps = { + className: null, + rootClassName: null, + defaultClasses: null, + textClassName: null, + title: null, + ingress: null, + background: null, + backgroundImage: null, + callToAction: null, + blocks: [], + options: null, +}; + +SectionFeatures.propTypes = { + sectionId: string.isRequired, + className: string, + rootClassName: string, + defaultClasses: shape({ + sectionDetails: string, + title: string, + ingress: string, + ctaButton: string, + }), + title: object, + ingress: object, + background: object, + backgroundImage: object, + callToAction: object, + blocks: arrayOf(propTypeBlock), + options: propTypeOption, +}; + +export default SectionFeatures; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css new file mode 100644 index 000000000..3a141d96e --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css @@ -0,0 +1,33 @@ +@import '../../../../styles/customMediaQueries.css'; + +.baseColumn { + max-width: var(--contentMaxWidth); + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 32px; + margin: 0 auto; + padding: 32px; +} + +.featuresMain { + composes: baseColumn; +} + +.block { + display: flex; + flex-direction: column; + align-items: center; + grid-auto-flow: dense; + gap: 0; + + @media (--viewportMedium) { + gap: 64px; + flex-direction: row-reverse; + } + + &:nth-child(even) { + @media (--viewportMedium) { + flex-direction: row; + } + } +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/index.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/index.js new file mode 100644 index 000000000..15afdeb50 --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/index.js @@ -0,0 +1,2 @@ +import SectionFeatures from './SectionFeatures'; +export default SectionFeatures; diff --git a/src/containers/PageBuilder/SectionBuilder/index.js b/src/containers/PageBuilder/SectionBuilder/index.js new file mode 100644 index 000000000..14479323f --- /dev/null +++ b/src/containers/PageBuilder/SectionBuilder/index.js @@ -0,0 +1,15 @@ +// Default wrapping element for block components +import SectionContainer from './SectionContainer'; + +// Section components +import SectionColumns from './SectionColumns'; +import SectionArticle from './SectionArticle'; +import SectionFeatures from './SectionFeatures'; +import SectionCarousel from './SectionCarousel'; + +// Main component: SectionBuilder +import SectionBuilder from './SectionBuilder'; + +export { SectionContainer, SectionColumns, SectionArticle, SectionFeatures, SectionCarousel }; + +export default SectionBuilder; diff --git a/src/styles/marketplaceDefaults.css b/src/styles/marketplaceDefaults.css index 5e9bd7872..e98065fc0 100644 --- a/src/styles/marketplaceDefaults.css +++ b/src/styles/marketplaceDefaults.css @@ -52,7 +52,9 @@ --fontWeightHighlightEmail: var(--fontWeightBold); - /* ================ Spacing unites ================ */ + /* ================ Spacing units ================ */ + + --contentMaxWidth: 1264px; /* calc(Global width (1200px) + (Horizontal padding (32px) * 2) */ /* Multiples of mobile and desktop spacing units should be used with margins and paddings. */ --spacingUnit: 6px; From 3089a583dabd8da1242bd3cbfdc454d85be1aa81 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 14 Jul 2022 20:53:39 +0300 Subject: [PATCH 10/99] Move StaticPage component --- src/containers/{StaticPage => PageBuilder}/StaticPage.js | 5 +++-- src/containers/StaticPage/README.md | 4 ---- src/containers/index.js | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) rename src/containers/{StaticPage => PageBuilder}/StaticPage.js (83%) delete mode 100644 src/containers/StaticPage/README.md diff --git a/src/containers/StaticPage/StaticPage.js b/src/containers/PageBuilder/StaticPage.js similarity index 83% rename from src/containers/StaticPage/StaticPage.js rename to src/containers/PageBuilder/StaticPage.js index 37b72fc85..969302ccd 100644 --- a/src/containers/StaticPage/StaticPage.js +++ b/src/containers/PageBuilder/StaticPage.js @@ -1,8 +1,9 @@ import React from 'react'; import { node } from 'prop-types'; import { connect } from 'react-redux'; -import { isScrollingDisabled } from '../../ducks/UI.duck'; -import { Page } from '../../components'; + +import { isScrollingDisabled } from '../../ducks/UI.duck.js'; +import { Page } from '../../components/index.js'; const StaticPageComponent = props => { const { children, ...pageProps } = props; diff --git a/src/containers/StaticPage/README.md b/src/containers/StaticPage/README.md deleted file mode 100644 index 2fd9d8c58..000000000 --- a/src/containers/StaticPage/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# StaticPage - -Component for creating static pages. See the -[Static pages documentation](../../../docs/static-pages.md) for more information. diff --git a/src/containers/index.js b/src/containers/index.js index 09dbbcaec..017f58aa5 100644 --- a/src/containers/index.js +++ b/src/containers/index.js @@ -1,3 +1,3 @@ export { default as NotFoundPage } from './NotFoundPage/NotFoundPage'; -export { default as StaticPage } from './StaticPage/StaticPage'; +export { default as StaticPage } from './PageBuilder/StaticPage'; export { default as TopbarContainer } from './TopbarContainer/TopbarContainer'; From 9d2372e9ee2cf7acbd0ad3a431a5eaf23429f634 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 14 Jul 2022 23:39:33 +0300 Subject: [PATCH 11/99] Add LayoutComposer component --- .../LayoutComposer/LayoutComposer.example.js | 155 ++++++++++++ .../LayoutComposer/LayoutComposer.js | 235 ++++++++++++++++++ .../PageBuilder/LayoutComposer/README.md | 94 +++++++ .../PageBuilder/LayoutComposer/index.js | 2 + 4 files changed, 486 insertions(+) create mode 100644 src/containers/PageBuilder/LayoutComposer/LayoutComposer.example.js create mode 100644 src/containers/PageBuilder/LayoutComposer/LayoutComposer.js create mode 100644 src/containers/PageBuilder/LayoutComposer/README.md create mode 100644 src/containers/PageBuilder/LayoutComposer/index.js diff --git a/src/containers/PageBuilder/LayoutComposer/LayoutComposer.example.js b/src/containers/PageBuilder/LayoutComposer/LayoutComposer.example.js new file mode 100644 index 000000000..60b54bca8 --- /dev/null +++ b/src/containers/PageBuilder/LayoutComposer/LayoutComposer.example.js @@ -0,0 +1,155 @@ +import React from 'react'; + +import LayoutComposer from './LayoutComposer.js'; + +// Wrapper with some inline styles +const GridContent = props => { + return ( +
      +

      {props.children}

      +
      + ); +}; + +// Component created using LayoutComposer +const Component = props => { + const { borderRadius, ...otherProps } = props; + return ( + + {props => { + const { Topbar, Main, Extra, Footer } = props; + return ( + <> + + + I went to the woods because I wished to live deliberately, + + + +
      + + ...to front only the essential facts of life, and see if I could not learn what it + had to teach... + +
      + + + + ...and not, when I came to die, discover that I had not lived. + + + +
      + + - Henry David Thoreau + +
      + + ); + }} +
      + ); +}; + +// Simple stacked layout using "areas" +export const LayoutComposerAreas = { + component: Component, + props: { + areas: ` + topbar + main + extra + footer + `, + }, + group: 'PageBuilder', +}; + +// Responsive layout using "responsiveAreas" +export const LayoutComposerResponsiveAreas = { + component: Component, + props: { + responsiveAreas: { + areasSmall: { + mediaQuery: '(max-width: 767px)', + areas: ` + topbar + main + extra + footer + `, + }, + areasMedium: { + mediaQuery: '(min-width: 768px) and (max-width: 1023px)', + areas: ` + topbar topbar + main extra + footer footer + `, + }, + areasLarge: { + mediaQuery: '(min-width: 1024px)', + areas: ` + topbar topbar topbar + main extra footer + `, + }, + }, + }, + group: 'PageBuilder', +}; + +// Responsive layout using "responsiveAreas" with bit more advanced grid +export const LayoutComposerResponsiveAreasFunky = { + component: Component, + props: { + style: { + gridTemplateRows: '100px 1fr 1fr 80px', + gap: '24px', + }, + borderRadius: '8px', + responsiveAreas: { + areasSmall: { + mediaQuery: '(max-width: 767px)', + areas: ` + topbar + main + extra + footer + `, + }, + areasMedium: { + mediaQuery: '(min-width: 768px) and (max-width: 1023px)', + areas: ` + topbar topbar + main . + main extra + footer footer + `, + }, + areasLarge: { + mediaQuery: '(min-width: 1024px)', + areas: ` + topbar topbar . + main . . + . extra . + . . footer + `, + }, + }, + }, + group: 'PageBuilder', +}; diff --git a/src/containers/PageBuilder/LayoutComposer/LayoutComposer.js b/src/containers/PageBuilder/LayoutComposer/LayoutComposer.js new file mode 100644 index 000000000..6f35033cf --- /dev/null +++ b/src/containers/PageBuilder/LayoutComposer/LayoutComposer.js @@ -0,0 +1,235 @@ +import React, { useEffect, useState } from 'react'; +import { func, node, objectOf, shape, string } from 'prop-types'; + +// We are standing on the shoulders of giants. +// This component is adapted from the great work done +// in React Layout Areas and Atomic Layouts projects! +// - https://github.com/giuseppeg/react-layout-areas +// - https://github.com/kettanaito/atomic-layout + +// Avoid parsing the same stuff over and over again. +const cache = {}; + +/** + * Parses CSS Grid template areas string. + * For example: + * ` + * topbar + * main + * footer + * ` + * + * @param {String} areas for CSS Grid + * @returns object containing generated Area *components* and *gridTemplateAreas*. E.g. "'topbar' 'main' 'footer' " + */ +const parseAreas = areas => { + if (cache.hasOwnProperty(areas)) { + return cache[areas]; + } + + // Split areas string to rows from line breaks and remove empty lines. + const splitToRows = areasString => + areasString + .trim() + .split('\n') + .filter(Boolean); + // Split rows to words (area names) from white-space and remove empty strings + const splitToAreaNames = rowString => rowString.split(/\s+/).filter(Boolean); + // kebab-case to camelCase + const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); + // Capitalize initial letter + const capitalizeWord = s => `${s.charAt(0).toUpperCase()}${s.substr(1)}`; + + const result = splitToRows(areas) + .map(row => splitToAreaNames(row)) + .reduce( + (result, areaNames) => { + areaNames.forEach(areaName => { + const Component = React.forwardRef((props, ref) => { + const { as, style, ...otherProps } = props; + const Tag = as || 'div'; + return ; + }); + + const displayName = camelize(capitalizeWord(areaName)); + Component.displayName = `LayoutArea.${displayName}`; + result.components[displayName] = Component; + }); + result.gridTemplateAreas += `'${areaNames.join(' ')}' `; + return result; + }, + { components: {}, gridTemplateAreas: '' } + ); + + cache[areas] = result; + return result; +}; + +// Handle resize event: +// if event matches with the rule set to MediaQueryList, call callback with parsed areas +const resize = (config, callback) => e => { + // If media query matches + if (e.matches) { + callback(parseAreas(config.areas)); + } +}; + +// Set the current areas according to responsiveAreas config and adds listeners for MediaQueryList changes +const handleResponsiveAreasOnBrowser = (responsiveAreas, setAreas) => { + let resizeListeners = []; + const entries = Object.entries(responsiveAreas); + entries.forEach(([name, config]) => { + const { mediaQuery, areas } = config; + const mediaQueryList = window.matchMedia(mediaQuery); + // Set areas if current viewport matches + if (mediaQueryList.matches) { + setAreas(parseAreas(areas)); + } + // Create listener for future matches of MQL rule + const resizeListener = resize(config, setAreas); + // Save MQL and listener for future "removeEventListener" call + resizeListeners.push({ mediaQueryList, resizeListener }); + // Add the created resizeListener to MQL + mediaQueryList.addEventListener('change', resizeListener); + }); + return resizeListeners; +}; + +// Parse default areas for state hook. +const parseDefaultAreasFromProps = props => { + const { areas, responsiveAreas } = props; + if (areas) { + return parseAreas(areas); + } else if (responsiveAreas) { + const firstKey = Object.keys(responsiveAreas)[0]; + const firstAreasString = responsiveAreas?.[firstKey]?.areas; + if (firstAreasString) { + return parseAreas(firstAreasString); + } + } + throw new Error( + 'LayoutComposer needs to have either "areas" or "responsiveAreas" included into props.' + ); +}; + +/** + * LayoutComposer creates container and area wrappers using CSS Grid Template Areas. + * Example: + * + * const layoutAreas = ` + * topbar + * main + * footer + * `; + * + * return ( + * + * {props => { + * const { Topbar, Main, Footer } = props; + * return ( + * <> + * + * Hello world! + * + *
      + * Some custom content. + *
      + *
      + * Contact us + *
      + * + * ); + * }} + *
      + * ); + * + * Note: "areas" and "responsiveAreas" are alternative props. + * For the "responsiveAreas", the content should look like this: + * { + * areasSmall: { + * mediaQuery: '(max-width: 767px)', + * areas: ` + * topbar + * main + * extra + * footer + * `, + * }, + * areasMedium: { + * mediaQuery: '(min-width: 768px) and (max-width: 1023px)', + * areas: ` + * topbar topbar + * main extra + * footer footer + * `, + * }, + * areasLarge: { + * mediaQuery: '(min-width: 1024px)', + * areas: ` + * topbar topbar topbar + * main extra footer + * `, + * }, + * } + * + * @param {Props} props for LayoutComposer (at least: children, style, areas, display, as) + * @return LayoutComposer that expects children to be a function. + */ +const LayoutComposer = React.forwardRef((props, ref) => { + const [currentAreas, setAreas] = useState(parseDefaultAreasFromProps(props)); + + useEffect(() => { + let resizeListeners = []; + if (responsiveAreas) { + resizeListeners = handleResponsiveAreasOnBrowser(responsiveAreas, setAreas); + } + + return () => { + resizeListeners.forEach(listener => { + const { mediaQueryList, resizeListener } = listener; + mediaQueryList.removeEventListener('change', resizeListener); + }); + }; + }, []); + + const { components, gridTemplateAreas } = currentAreas; + const { children, style = {}, areas, responsiveAreas, display, as, ...otherProps } = props; + const Tag = as || 'div'; + + return ( + + {children(components)} + + ); +}); +LayoutComposer.displayName = 'LayoutComposer'; + +LayoutComposer.defaultProps = { + areas: null, + responsiveAreas: null, + display: 'grid', + as: null, +}; + +LayoutComposer.propTypes = { + children: func.isRequired, + areas: string, + responsiveAreas: objectOf( + shape({ + mediaQuery: string.isRequired, + areas: string.isRequired, + }) + ), + display: string, + as: node, +}; + +export default LayoutComposer; diff --git a/src/containers/PageBuilder/LayoutComposer/README.md b/src/containers/PageBuilder/LayoutComposer/README.md new file mode 100644 index 000000000..e0c469cbf --- /dev/null +++ b/src/containers/PageBuilder/LayoutComposer/README.md @@ -0,0 +1,94 @@ +# LayoutComposer + +LayoutComposer helps to create layout areas using CSS Grid (grid-template-areas). + +This component is adapted from the great work done in React Layout Areas and Atomic Layouts +projects. + +- https://github.com/giuseppeg/react-layout-areas +- https://github.com/kettanaito/atomic-layout + +## How to use LayoutComposer + +You need to define the "**areas**" prop that contains a templating string. LayoutComposer reads it +and generates container components with similar names that you can use to wrap your child +components. + +```jsx + + {props => { + const { Topbar, Main, Footer } = props; + return ( + <> + + + +
      + +
      +
      + +
      + + ); + }} +
      +``` + +## Responsive areas + +LayoutComposer has alternative prop to "areas": "**responsiveAreas**". However, consider this +feature a bit experimental. + +Responsive areas are created using **window.matchMedia**, which means that responsive layouts are +only created on the client-side (on a browser). This is problematic for server-side rendered pages: +the component needs to pick one of the defined areas (the first one) on the server, because there it +doesn't have window/screen dimensions available. So, for server-side rendered pages, _the initial +layout flashes when a user makes a full page load_. + +```js +const responsiveAreas = { + areasSmall: { + mediaQuery: '(max-width: 767px)', + areas: ` + topbar + main + aside + footer + `, + }, + areasMedium: { + mediaQuery: '(min-width: 768px)', + areas: ` + topbar topbar + main aside + footer footer + `, + }, +}; +const ResponsiveLayout = ( + + {props => { + const { Topbar, Main, Aside, Footer } = props; + return ( + <> + + + +
      Main content
      + +
      + +
      + + ); + }} +
      +); +``` diff --git a/src/containers/PageBuilder/LayoutComposer/index.js b/src/containers/PageBuilder/LayoutComposer/index.js new file mode 100644 index 000000000..6f030738a --- /dev/null +++ b/src/containers/PageBuilder/LayoutComposer/index.js @@ -0,0 +1,2 @@ +import LayoutComposer from './LayoutComposer.js'; +export default LayoutComposer; From 6876a507d215245580199a2435e8d4883e5206be Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 15 Jul 2022 01:07:56 +0300 Subject: [PATCH 12/99] Add markdown processor --- .../PageBuilder/Markdown.example.js | 629 ++++++++++++++++++ .../PageBuilder/markdownProcessor.js | 26 + 2 files changed, 655 insertions(+) create mode 100644 src/containers/PageBuilder/Markdown.example.js create mode 100644 src/containers/PageBuilder/markdownProcessor.js diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js new file mode 100644 index 000000000..c860be374 --- /dev/null +++ b/src/containers/PageBuilder/Markdown.example.js @@ -0,0 +1,629 @@ +import React from 'react'; + +import { Code, CodeBlock } from './Primitives/Code/Code.js'; + +import renderMarkdown from './markdownProcessor.js'; + +import PageBuilder from './PageBuilder.js'; + +const addCodeBlockForSyntax = md => ` +\`\`\`${md}\`\`\` + +${md} +`; + +const mdHeading = ` +# h1 Heading +## h2 Heading +### h3 Heading +#### h4 Heading +##### h5 Heading +###### h6 Heading + +This is an H1 +============= + +This is an H2 +------------- +`; + +const SectionHeadings = { + sectionType: 'columns', + sectionId: 'cms-section-1', + numColumns: 2, + title: { type: 'heading2', content: 'Headings' }, + ingress: { type: 'heading2', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-heading-block-1', + title: { type: 'heading3', content: 'Heading syntax' }, + text: { type: 'markdown', content: `\`\`\`${mdHeading}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-heading-block-2', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: mdHeading }, + }, + ], +}; + +const emphasisBold = ` +This is **bold text** +`; + +const emphasisBold2 = ` +This is __bold text__ +`; + +const emphasisItalic = ` +This is *italic text* +`; + +const emphasisItalic2 = ` +This is _italic text_ +`; + +const SectionEmphasis = { + sectionType: 'columns', + sectionId: 'cms-section-2', + numColumns: 4, + title: { type: 'heading2', content: 'Emphasizing text' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-emphasis-block-1', + title: { type: 'heading3', content: 'Bold' }, + text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisBold) }, + }, + { + blockType: 'default-block', + blockId: 'cms-emphasis-block-2', + title: { type: 'heading3', content: 'Bold' }, + text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisBold2) }, + }, + { + blockType: 'default-block', + blockId: 'cms-emphasis-block-3', + title: { type: 'heading3', content: 'Italic' }, + text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisItalic) }, + }, + { + blockType: 'default-block', + blockId: 'cms-emphasis-block-4', + title: { type: 'heading3', content: 'Italic' }, + text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisItalic2) }, + }, + ], +}; + +const mdLinks = ` +Plain [link text](https://www.sharetribe.com/docs/) within a parapgraph. + +[Link with title](https://www.sharetribe.com/docs/ "title text!") shows a title text, when mouse is hovering on top of it. + +[In-app link](/s) starts with "/" I.e. use absolute path after marketplace domain. + +Go to [Styleguide > Markdown page](/styleguide/c/Markdown "Markdown syntax page") +`; + +const SectionLinks = { + sectionType: 'columns', + sectionId: 'cms-section-3', + numColumns: 2, + title: { type: 'heading2', content: 'Links' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-link-block-1', + title: { type: 'heading3', content: 'Link syntax' }, + text: { type: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-link-block-2', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: mdLinks }, + }, + ], +}; + +const horizontalRules = ` +Some text +___ + +divided by horizontal rule +`; + +const horizontalRules2 = ` +Some text + +--- + +divided by horizontal rule +`; + +const horizontalRules3 = ` +Some text + +*** + +divided by horizontal rule +`; + +const SectionHorizontalRules = { + sectionType: 'columns', + sectionId: 'cms-section-4', + numColumns: 3, + title: { type: 'heading2', content: 'Horizontal Rules' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-hr-block-1', + title: { type: 'heading3', content: 'With 3 underscore' }, + text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules) }, + }, + { + blockType: 'default-block', + blockId: 'cms-hr-block-2', + title: { type: 'heading3', content: 'With 3 dash' }, + text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules2) }, + }, + { + blockType: 'default-block', + blockId: 'cms-hr-block-3', + title: { type: 'heading3', content: 'With 3 asterisk' }, + text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules3) }, + }, + ], +}; + +const unorderedList = ` ++ Create a list by starting a line with +, -, or * ++ Sub-lists are made by indenting 2 spaces: + - Marker character change forces new list start: + + List with asterisk (*): + * Red + * Green + * Blue + + List with plus (+): + + Red + + Green + + Blue + + List with dash (-): + - Red + - Green + - Blue +`; + +const orderedList = ` +1. Lorem ipsum +2. dolor sit amet +3. Consectetur +4. adipiscing elit +`; + +const orderedList2 = ` +1. Lorem ipsum +1. dolor sit amet +1. Consectetur +1. adipiscing elit +`; + +const orderedList3 = ` +42. Lorem ipsum +1. dolor sit amet +1. Consectetur +1. adipiscing elit +`; + +const SectionLists = { + sectionType: 'columns', + sectionId: 'cms-section-5', + numColumns: 2, + title: { type: 'heading2', content: 'Lists' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-list-block-1', + title: { type: 'heading3', content: 'Unordered lists' }, + text: { type: 'markdown', content: `\`\`\`${unorderedList}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-list-block-2', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: unorderedList }, + }, + { + blockType: 'default-block', + blockId: 'cms-list-block-3', + title: { type: 'heading3', content: 'Ordered lists' }, + text: { type: 'markdown', content: `\`\`\`${orderedList}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-list-block-4', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: orderedList }, + }, + { + blockType: 'default-block', + blockId: 'cms-list-block-5', + title: { type: 'heading3', content: 'Keep all numbers as "1."' }, + text: { type: 'markdown', content: `\`\`\`${orderedList2}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-list-block-6', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: orderedList2 }, + }, + { + blockType: 'default-block', + blockId: 'cms-list-block-7', + title: { type: 'heading3', content: 'Start numbering with offset' }, + text: { type: 'markdown', content: `\`\`\`${orderedList3}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-list-block-8', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: orderedList3 }, + }, + ], +}; + +const blockquotesNested = ` +> Blockquotes can also be nested... +>> ...by stacking greater-than signs... +> > > ...or with spaces between arrows. +`; + +const blockquotesLazyArray = ` +> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, +consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. +Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. + +> Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse +id sem consectetuer libero luctus adipiscing. +`; + +const blockquotesComplex = ` +> ## This is a header (H2) +> +> Blockquotes can contain other Markdown elements, including headers, lists, and code blocks +> +> 1. This is the first list item. +> 2. This is the second list item. +> +> Here's some example code: +> +> export default PageBuilder; +`; + +const SectionBlockquotes = { + sectionType: 'columns', + sectionId: 'cms-section-6', + numColumns: 2, + title: { type: 'heading2', content: 'Blockquotes' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-blockquote-block-1', + title: { type: 'heading3', content: 'Nested blockquotes' }, + text: { type: 'markdown', content: `\`\`\`${blockquotesNested}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-blockquote-block-2', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: blockquotesNested }, + }, + { + blockType: 'default-block', + blockId: 'cms-blockquote-block-3', + title: { type: 'heading3', content: 'Lazy arrow:' }, + text: { type: 'markdown', content: `\`\`\`${blockquotesLazyArray}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-blockquote-block-4', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: blockquotesLazyArray }, + }, + { + blockType: 'default-block', + blockId: 'cms-blockquote-block-5', + title: { type: 'heading3', content: 'Complex blockquotes' }, + text: { type: 'markdown', content: `\`\`\`${blockquotesComplex}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-blockquote-block-6', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: blockquotesComplex }, + }, + ], +}; + +const mdImage1 = ` +![Alt text](https://picsum.photos/400) +`; + +const mdImage2 = ` +![Alt text](https://picsum.photos/400 "Title text") +`; + +const mdImageFootnoteStyle = ` +![Alt text][id] + +Like links, Images also have a footnote style syntax with a reference later in the markdown content defining the URL location: + +[id]: https://picsum.photos/400 "Title text" +`; + +const SectionImages = { + sectionType: 'columns', + sectionId: 'cms-section-7', + numColumns: 2, + title: { type: 'heading2', content: 'Images' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-image-block-1', + title: { type: 'heading3', content: 'With "alt" for screenreaders' }, + text: { type: 'markdown', content: `\`\`\`${mdImage1}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-image-block-2', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: mdImage1 }, + }, + { + blockType: 'default-block', + blockId: 'cms-image-block-3', + title: { type: 'heading3', content: 'With "alt" and "title"' }, + text: { type: 'markdown', content: `\`\`\`${mdImage2}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-image-block-4', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: mdImage2 }, + }, + { + blockType: 'default-block', + blockId: 'cms-image-block-5', + title: { type: 'heading3', content: 'Footnote style' }, + text: { type: 'markdown', content: `\`\`\`${mdImageFootnoteStyle}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-image-block-6', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: mdImageFootnoteStyle }, + }, + ], +}; + +const inlineCode = ` +Inline \`code\` +`; + +const codeBlockIndentation = ` + // Some comments + line 1 of code + line 2 of code + line 3 of code +`; + +const codeBlockFences = ` +\`\`\` +Some text here... +\`\`\` +`; + +const codeBlockFencesSyntax = ` + \`\`\` + Some text here... + \`\`\` +`; + +const SectionCode = { + sectionType: 'columns', + sectionId: 'cms-section-8', + numColumns: 2, + title: { type: 'heading2', content: 'Inline code and Code blocks' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-code-block-1', + title: { type: 'heading3', content: 'Inline code uses backticks' }, + text: { type: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-2', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: inlineCode }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-3', + title: { type: 'heading3', content: 'Code block with indentation (4 spaces)' }, + text: { type: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-4', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: codeBlockIndentation }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-5', + title: { type: 'heading3', content: 'Code block with backtick "fences" (```)' }, + text: { type: 'markdown', content: codeBlockFencesSyntax }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-6', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: codeBlockFences }, + }, + ], +}; + +const MarkdownPage = props => { + const sections = props.sections; + const pageAssetsData = { sections }; + return ( + + ); +}; + +export const Syntax = { + component: MarkdownPage, + props: { + sections: [ + SectionHeadings, + SectionEmphasis, + SectionLinks, + SectionHorizontalRules, + SectionLists, + SectionBlockquotes, + SectionImages, + SectionCode, + ], + }, + group: 'PageBuilder', +}; + +///////////////// +// Theme: dark // +///////////////// +const SectionLinksOnDarkMode = { + sectionType: 'columns', + sectionId: 'cms-section-3-dark', + numColumns: 2, + background: { type: 'hexColor', color: '#000000' }, + textColor: 'light', + title: { type: 'heading2', content: 'Links on dark theme' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-link-block-1-dark', + title: { type: 'heading3', content: 'Link syntax' }, + text: { type: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-link-block-2-dark', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: mdLinks }, + }, + ], +}; + +const SectionCodeOnDarkMode = { + sectionType: 'columns', + sectionId: 'cms-section-8', + numColumns: 2, + background: { type: 'hexColor', color: '#000000' }, + textColor: 'light', + title: { type: 'heading2', content: 'Inline code and Code blocks' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'cms-code-block-1', + title: { type: 'heading3', content: 'Inline code uses backticks' }, + text: { type: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-2', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: inlineCode }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-3', + title: { type: 'heading3', content: 'Code block with indentation (4 spaces)' }, + text: { type: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-4', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: codeBlockIndentation }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-5', + title: { type: 'heading3', content: 'Code block with backtick "fences" (```)' }, + text: { type: 'markdown', content: codeBlockFencesSyntax }, + }, + { + blockType: 'default-block', + blockId: 'cms-code-block-6', + title: { type: 'heading3', content: '...rendered' }, + text: { type: 'markdown', content: codeBlockFences }, + }, + ], +}; + +export const SyntaxOnDarkTheme = { + component: MarkdownPage, + props: { + sections: [SectionLinksOnDarkMode, SectionCodeOnDarkMode], + }, + group: 'PageBuilder', +}; + +const MarkdownDiv = props =>
      {props.renderedMarkdown}
      ; + +const mdText = ` +\`\`\` +import renderMarkdown from './markdownProcessor'; +const MyItalics = props => ; +const MyStrong = props => ; + +const mdText = \'\n#Hello Markdown\nSome _styled_ **text**!\n\;' + +const Markdown = () => ( +
      + {renderMarkdown(mdText, { + em: MyItalics, + strong: MyStrong, + })} +
      +); +\`\`\` + +# Hello Markdown + +Some _styled_ **text**! +`; + +const MyItalics = props => ; +const MyStrong = props => ; + +export const markdownProcessingWithCustomComponents = { + component: MarkdownDiv, + props: { + renderedMarkdown: renderMarkdown(mdText, { + em: MyItalics, + strong: MyStrong, + code: Code, + pre: CodeBlock, + }), + }, + group: 'PageBuilder', +}; diff --git a/src/containers/PageBuilder/markdownProcessor.js b/src/containers/PageBuilder/markdownProcessor.js new file mode 100644 index 000000000..e21c6f6b4 --- /dev/null +++ b/src/containers/PageBuilder/markdownProcessor.js @@ -0,0 +1,26 @@ +import { createElement, Fragment } from 'react'; +// cjs module +import { default as unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remark2rehype from 'remark-rehype'; +// If you need to support HTML tags, remember to sanitize the output +// https://github.com/remarkjs/remark-rehype#example-supporting-html-in-markdown-properly +import rehypeSanitize from 'rehype-sanitize'; +import rehypeReact from 'rehype-react'; + +const processor = (components = {}) => { + return unified() + .use(remarkParse) + .use(remark2rehype) + .use(rehypeSanitize) + .use(rehypeReact, { + createElement, + Fragment, + components, + }); +}; + +const renderMarkdown = (markdownText, components) => { + return processor(components).processSync(markdownText).result; +}; +export default renderMarkdown; From ede8883ec34aa124aa2f2c070cd4b1c16eb6f96b Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 15 Jul 2022 01:08:29 +0300 Subject: [PATCH 13/99] Add PageBuilder component --- .../PageBuilder/PageBuilder.example.js | 305 ++++++++++++++++++ src/containers/PageBuilder/PageBuilder.js | 76 +++++ .../PageBuilder/PageBuilder.module.css | 14 + src/containers/PageBuilder/README.md | 101 ++++++ 4 files changed, 496 insertions(+) create mode 100644 src/containers/PageBuilder/PageBuilder.example.js create mode 100644 src/containers/PageBuilder/PageBuilder.js create mode 100644 src/containers/PageBuilder/PageBuilder.module.css create mode 100644 src/containers/PageBuilder/README.md diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js new file mode 100644 index 000000000..1c52494d0 --- /dev/null +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -0,0 +1,305 @@ +import React from 'react'; + +import { H1 } from './Primitives/Heading/index.js'; +import PageBuilder from './PageBuilder.js'; + +const hexYellow = '#FFAA00'; + +//////////////////////////// +// Denormalized image ref // +//////////////////////////// + +const imagePlaceholder = (width, height) => ({ + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/${width}/${height}`, + width, + height, + }, + square2x: { + url: `https://picsum.photos/${2 * width}/${2 * height}`, + width: 2 * width, + height: 2 * width, + }, + }, + }, +}); + +const Placeholder = props => ( +
      +

      Blaa

      +
      +); + +export const PageWithOneSection = { + component: PageBuilder, + props: { + pageAssetsData: { + sections: [ + { + sectionType: 'custom-a', + sectionId: 'custom-a', + }, + ], + }, + options: { + sectionComponents: { + ['custom-a']: { component: Placeholder }, + }, + }, + description: 'Example page by PageBuilder', + title: 'Styleguide page', + }, + group: 'PageBuilder', + rawOnly: true, +}; + +export const PageWith3Sections = { + component: PageBuilder, + props: { + pageAssetsData: { + sections: [ + { + sectionType: 'custom-a', + sectionId: 'custom-a', + }, + { + sectionType: 'custom-b', + sectionId: 'custom-b', + }, + { + sectionType: 'custom-c', + sectionId: 'custom-c', + }, + ], + }, + options: { + sectionComponents: { + ['custom-a']: { component: Placeholder }, + ['custom-b']: { component: Placeholder }, + ['custom-c']: { component: Placeholder }, + }, + }, + description: 'Example page by PageBuilder', + title: 'Styleguide page', + }, + group: 'PageBuilder', + rawOnly: true, +}; + +const PlaceholderTall = () => ; + +export const PageWith3xHeight = { + component: PageBuilder, + props: { + pageAssetsData: { + sections: [ + { + sectionType: 'custom-a', + sectionId: 'custom-a', + }, + { + sectionType: 'custom-b', + sectionId: 'custom-b', + }, + { + sectionType: 'custom-c', + sectionId: 'custom-c', + }, + ], + }, + options: { + sectionComponents: { + ['custom-a']: { component: PlaceholderTall }, + ['custom-b']: { component: PlaceholderTall }, + ['custom-c']: { component: PlaceholderTall }, + }, + }, + description: 'Example page by PageBuilder', + title: 'Styleguide page', + }, + group: 'PageBuilder', + rawOnly: true, +}; + +export const PageWithBuildInSectionColumns = { + component: PageBuilder, + props: { + pageAssetsData: { + sections: [ + { + sectionType: 'columns', + sectionId: 'page-builder-columns-section-0', + numColumns: 1, + title: { type: 'heading2', content: 'One Column' }, + ingress: { + type: 'heading2', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'page-builder-columns-section-0-block-1', + title: { type: 'heading3', content: 'Column 1' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet, consectetur adipiscing elit. Nulla orci nisi, lobortis sit amet posuere et, vulputate sit amet neque. Nam a est id lectus viverra sagittis. Proin sed imperdiet lorem. Duis aliquam fermentum purus, tincidunt venenatis felis gravida in. Sed imperdiet mi vitae consequat rhoncus. Sed velit leo, porta at lorem ac, iaculis fermentum leo. Morbi tellus orci, bibendum id ante vel, hendrerit efficitur lectus. Proin vitae condimentum justo. Phasellus finibus nisi quis neque feugiat, ac auctor ipsum suscipit.`, + }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'page-builder-columns-section-1', + numColumns: 2, + background: { type: 'hexColor', color: hexYellow }, + title: { type: 'heading2', content: '2 Columns' }, + ingress: { + type: 'heading2', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'page-builder-columns-section-1-block-1', + title: { type: 'heading3', content: 'Column 1' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + }, + { + blockType: 'default-block', + blockId: 'page-builder-columns-section-1-block-2', + title: { type: 'heading3', content: 'Column 2' }, + text: { + type: 'markdown', + content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, + }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'page-builder-columns-section-2', + numColumns: 2, + title: { type: 'heading2', content: '2 Columns' }, + ingress: { + type: 'heading2', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'page-builder-columns-section-2-block-1', + title: { type: 'heading3', content: 'Column 1' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + { + blockType: 'default-block', + blockId: 'page-builder-columns-section-2-block-2', + title: { type: 'heading3', content: 'Column 2' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'page-builder-columns2-section-3', + numColumns: 3, + backgroundImage: { + type: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800, '#b6f7f9'), + }, + title: { type: 'heading2', content: '3 Columns' }, + ingress: { + type: 'heading2', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + textColor: 'light', + blocks: [ + { + blockType: 'default-block', + blockId: 'page-builder-columns2-section-3-block-1', + title: { type: 'heading3', content: 'Column 1' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + { + blockType: 'default-block', + blockId: 'page-builder-columns2-section-3-block-2', + title: { type: 'heading3', content: 'Column 2' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + { + blockType: 'default-block', + blockId: 'page-builder-columns2-section-3-block-3', + title: { type: 'heading3', content: 'Column 3' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + ], + }, + { + sectionType: 'columns', + sectionId: 'page-builder-columns2-section-4', + numColumns: 4, + title: { type: 'heading2', content: '4 Columns' }, + ingress: { + type: 'heading2', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, + blocks: [ + { + blockType: 'default-block', + blockId: 'page-builder-columns2-section-4-block-1', + title: { type: 'heading3', content: 'Column 1' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + { + blockType: 'default-block', + blockId: 'page-builder-columns2-section-4-block-2', + title: { type: 'heading3', content: 'Column 2' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + { + blockType: 'default-block', + blockId: 'page-builder-columns2-section-4-block-3', + title: { type: 'heading3', content: 'Column 3' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + { + blockType: 'default-block', + blockId: 'page-builder-columns2-section-4-block-4', + title: { type: 'heading3', content: 'Column 4' }, + media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + }, + ], + }, + ], + }, + options: { + sectionComponents: { + ['custom-c']: { component: PlaceholderTall }, + }, + }, + description: 'Example page by PageBuilder', + title: 'Styleguide page', + }, + group: 'PageBuilder', + rawOnly: true, +}; diff --git a/src/containers/PageBuilder/PageBuilder.js b/src/containers/PageBuilder/PageBuilder.js new file mode 100644 index 000000000..5f75db3d3 --- /dev/null +++ b/src/containers/PageBuilder/PageBuilder.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { arrayOf, func, node, oneOf, shape, string } from 'prop-types'; + +import { Footer as FooterContent } from '../../components/index.js'; +import { TopbarContainer } from '../../containers/index.js'; + +import LayoutComposer from './LayoutComposer/index.js'; +import SectionBuilder from './SectionBuilder/SectionBuilder.js'; +import StaticPage from './StaticPage.js'; + +import css from './PageBuilder.module.css'; + +////////////////// +// Page Builder // +////////////////// + +/** + * PageBuilder can be used to build content pages using page-asset.json. + * + * Note: props can include a lot of things that depend on + * - pageAssetsData: json asset that contains instructions how to build the page content + * - asset should contain an array of _sections_, which might contain _fields_ and an array of _blocks_ + * - _blocks_ can also contain _fields_ + * - fallbackPage: component. If asset loading fails, this is used instead. + * - options: extra mapping of 3 level of sub components + * - sectionComponents: { ['my-section-type']: { component: MySection } } + * - blockComponents: { ['my-component-type']: { component: MyBlock } } + * - fieldComponents: { ['my-field-type']: { component: MyField, pickValidProps: data => Number.isInteger(data.content) ? { content: data.content } : {} } + * - fields have this pickValidProps as an extra requirement for data validation. + * - pageProps: props that are passed to src/components/Page/Page.js component + * + * @param {Object} props + * @returns page component + */ +const PageBuilder = props => { + const { pageAssetsData, inProgress, fallbackPage, options, ...pageProps } = props; + + if (!pageAssetsData && fallbackPage && !inProgress) { + return fallbackPage; + } + + const data = pageAssetsData || {}; + const sectionsData = data?.sections || []; + + const layoutAreas = ` + topbar + main + footer + `; + return ( + + + {props => { + const { Topbar, Main, Footer } = props; + return ( + <> + + + +
      + +
      +
      + +
      + + ); + }} +
      +
      + ); +}; + +export { StaticPage }; + +export default PageBuilder; diff --git a/src/containers/PageBuilder/PageBuilder.module.css b/src/containers/PageBuilder/PageBuilder.module.css new file mode 100644 index 000000000..c901c6d33 --- /dev/null +++ b/src/containers/PageBuilder/PageBuilder.module.css @@ -0,0 +1,14 @@ +.layout { + min-height: 100vh; + grid-template-rows: auto 1fr auto; +} + +.topbar { + position: sticky; + top: 0; + z-index: var(--zIndexTopbar); +} + +.main { + display: grid; +} diff --git a/src/containers/PageBuilder/README.md b/src/containers/PageBuilder/README.md new file mode 100644 index 000000000..056b629ec --- /dev/null +++ b/src/containers/PageBuilder/README.md @@ -0,0 +1,101 @@ +## PageBuilder + +PageBuilder creates a page according to a page data asset. The page asset represents all the content +that the page needs and how they are grouped together (excluding the top bar and footer). + +The page asset file is created in Flex Console against the page asset schema. When comparing this +solution with headless CMS services, the schema of the page asset represents the result of **content +modeling**. It defines what kind of data needs to be asked from a content writer. In Flex, content +writing happens in Console, which means that content writers are marketplace operators. + +The smallest piece of information in page asset is a field. It defines a piece of data and its type. +For example: + +```json +"title": { + "type": "heading1", + "content": "Hello World" +} +``` + +The default asset schema for page content has 3 levels that can include content fields: + +- **page asset** (data from Asset Delivery API) + - **sections** (page asset contains an array of sections) + - **blocks** (section might contain an array of blocks) + +**PageBuilder** reads the page asset, and when it gets to the _sections_ array, it uses +**SectionBuilder** to render its content. Similarly, SectionBuilder passes _blocks_ array to +**BlockBuilder**. All the fields are passed to the **Field** component, which validates and +sanitizes the data and uses **Primitive** components to actually render them. **MarkdownProcessor** +is used to render fields with `"type": "markdown"`. + +Then **LayoutComposer** creates the layout areas for **Topbar**, **Footer**, and for the main +content, which is created using a page asset. + +In addition, the **StaticPage** component wraps everything and adds the page context using +[React Helmet Async](https://github.com/staylor/react-helmet-async) library. It sets the title, +description, SEO schema, and social media meta tags to the `` section of the page. There are +also other responsibilities that StaticPage takes care of. + +## Props + +- **pageAssetsData**: denormalized page asset data (e.g. image refs are swapped to imageAsset + entities) +- **inProgress**: status of Asset Delivery API call to fetch the asset +- **fallbackPage**: if asset fetch fails, you can provide a fallback component +- **options**: possibility to extend built-in sections, blocks, and fields. +- All the other props are given to the **StaticPage** component, which PageBuilder uses internally. + +## Extend PageBuilder + +By default, PageBuilder has only one layout that consists of 3 parts: topbar, main, and footer. You +might want to create more layout options using **LayoutComposer**. + +It's also possible to create custom section types, block types, and fields - and map those with your +custom components. However, this is only useful if PageBuilder is used to create custom pages that +don't get content through the Asset Delivery API. + +```jsx + (hasBar(data) ? { bar: data.bar } : {}), + }, + }, + }} + contentType={openGraphContentType} + description={description} + title={title} + schema={pageSchemaForSEO} +/> +``` From ddebfec8e0bc29279336a6d9d8f8c063cfc4754a Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 15 Jul 2022 01:10:39 +0300 Subject: [PATCH 14/99] Add util function to denormalize asset data --- src/util/data.js | 56 ++++++++++++++++++++++++++++++++ src/util/data.test.js | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/util/data.js b/src/util/data.js index bec6479a9..0f564cd06 100644 --- a/src/util/data.js +++ b/src/util/data.js @@ -140,6 +140,62 @@ export const denormalisedResponseEntities = sdkResponse => { return denormalisedEntities(entities, resources); }; +/** + * Denormalize JSON object. + * NOTE: Currently, this only handles denormalization of image references + * + * @param {JSON} data from Asset API (e.g. page asset) + * @param {JSON} included array of asset references (currently only images supported) + * @returns deep copy of data with images denormalized into it. + */ +const denormalizeJsonData = (data, included) => { + let copy; + + // Handle strings, numbers, booleans, null + if (data === null || typeof data !== 'object') { + return data; + } + + // At this point the data has typeof 'object' (aka Array or Object) + // Array is the more specific case (of Object) + if (data instanceof Array) { + copy = data.map(datum => denormalizeJsonData(datum, included)); + return copy; + } + + // Generic Objects + if (data instanceof Object) { + copy = {}; + Object.entries(data).forEach(([key, value]) => { + // Handle denormalization of image reference + const hasImageRefAsValue = + typeof value == 'object' && + value._ref && + value._ref?.type === 'imageAsset' && + value._ref?.id; + if (hasImageRefAsValue) { + const foundRef = included.find(inc => inc.id === value._ref?.id); + copy[key] = foundRef; + } else { + copy[key] = denormalizeJsonData(value, included); + } + }); + return copy; + } + + throw new Error("Unable to traverse data! It's not JSON."); +}; + +/** + * Denormalize asset json from Asset API. + * @param {JSON} assetJson in format: { data, included } + * @returns deep copy of asset data with images denormalized into it. + */ +export const denormalizeAssetData = assetJson => { + const { data, included } = assetJson || {}; + return denormalizeJsonData(data, included); +}; + /** * Create shell objects to ensure that attributes etc. exists. * diff --git a/src/util/data.test.js b/src/util/data.test.js index 63ef11fa0..1a9787a06 100644 --- a/src/util/data.test.js +++ b/src/util/data.test.js @@ -5,6 +5,7 @@ import { updatedEntities, denormalisedEntities, humanizeLineItemCode, + denormalizeAssetData, } from './data'; const { UUID } = sdkTypes; @@ -346,3 +347,77 @@ describe('humanizeLineItemCode', () => { expect(() => humanizeLineItemCode('line-item/')).toThrowError(Error); }); }); + +describe('denormalizeAssetData', () => { + const jsonObjData = { + foo: 'bar', + num: 6, + b: true, + nested: { foo: 'bar2' }, + arr: ['a', { b: 'b' }, { c: { data: 'c' } }], + }; + + it('should deep clone asset without image references', () => { + const jsonObj = { + data: jsonObjData, + included: [], + }; + expect(JSON.stringify(denormalizeAssetData(jsonObj))).toEqual(JSON.stringify(jsonObj.data)); + }); + + it('should deep clone asset with image references', () => { + const jsonObj = { + data: { + ...jsonObjData, + image: { + _ref: { + id: 'hero-1', + type: 'imageAsset', + }, + }, + }, + included: [ + { + id: 'hero-1', + type: 'imageAsset', + attributes: { + variants: { + scaled: { + url: 'https://something.imgix.com/foo/bar/baz', + width: 1200, + height: 580, + }, + scaled2x: { + url: 'https://something.imgix.com/foo/bar/else', + width: 2400, + height: 1160, + }, + }, + }, + }, + ], + }; + const expected = { + ...jsonObjData, + image: { + id: 'hero-1', + type: 'imageAsset', + attributes: { + variants: { + scaled: { + url: 'https://something.imgix.com/foo/bar/baz', + width: 1200, + height: 580, + }, + scaled2x: { + url: 'https://something.imgix.com/foo/bar/else', + width: 2400, + height: 1160, + }, + }, + }, + }, + }; + expect(JSON.stringify(denormalizeAssetData(jsonObj))).toEqual(JSON.stringify(expected)); + }); +}); From 2c173ac32f45a87b98a1b0035613332015992f2c Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 15 Jul 2022 01:11:43 +0300 Subject: [PATCH 15/99] hostedAssets.duck: add fetchPageAssets --- src/ducks/hostedAssets.duck.js | 91 +++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/src/ducks/hostedAssets.duck.js b/src/ducks/hostedAssets.duck.js index 8af87b722..f5917a9d7 100644 --- a/src/ducks/hostedAssets.duck.js +++ b/src/ducks/hostedAssets.duck.js @@ -1,3 +1,4 @@ +import { denormalizeAssetData } from '../util/data'; import { storableError } from '../util/errors'; import * as log from '../util/log'; @@ -7,12 +8,17 @@ export const ASSETS_REQUEST = 'app/assets/REQUEST'; export const ASSETS_SUCCESS = 'app/assets/SUCCESS'; export const ASSETS_ERROR = 'app/assets/ERROR'; +export const PAGE_ASSETS_REQUEST = 'app/assets/PAGE_ASSETS_REQUEST'; +export const PAGE_ASSETS_SUCCESS = 'app/assets/PAGE_ASSETS_SUCCESS'; +export const PAGE_ASSETS_ERROR = 'app/assets/PAGE_ASSETS_ERROR'; + // ================ Reducer ================ // const initialState = { - // List of assets that should be fetched and their path in Asset API. - // assets: { assetName: 'path/to/asset.json' } - assets: {}, + // List of app-wide assets that should be fetched and their path in Asset API. + // appAssets: { assetName: 'path/to/asset.json' } + appAssets: {}, + pageAssetsData: null, // Current version of the saved asset. // Typically, the version that is returned by the "latest" alias. version: null, @@ -28,12 +34,19 @@ export default function assetReducer(state = initialState, action = {}) { case ASSETS_SUCCESS: return { ...state, - assets: payload.assets, - version: payload.version, + appAssets: payload.assets, + version: state.version || payload.version, inProgress: false, }; case ASSETS_ERROR: - return { ...state, inProgress: true, error: payload }; + return { ...state, inProgress: false, error: payload }; + + case PAGE_ASSETS_REQUEST: + return { ...state, inProgress: true, error: null }; + case PAGE_ASSETS_SUCCESS: + return { ...state, pageAssetsData: payload, inProgress: false }; + case PAGE_ASSETS_ERROR: + return { ...state, inProgress: false, error: payload }; default: return state; @@ -42,20 +55,27 @@ export default function assetReducer(state = initialState, action = {}) { // ================ Action creators ================ // -export const assetsRequested = () => ({ type: ASSETS_REQUEST }); -export const assetsSuccess = (assets, version) => ({ +export const appAssetsRequested = () => ({ type: ASSETS_REQUEST }); +export const appAssetsSuccess = (assets, version) => ({ type: ASSETS_SUCCESS, payload: { assets, version }, }); -export const assetsError = error => ({ +export const appAssetsError = error => ({ type: ASSETS_ERROR, payload: error, }); +export const pageAssetsRequested = () => ({ type: PAGE_ASSETS_REQUEST }); +export const pageAssetsSuccess = assets => ({ type: PAGE_ASSETS_SUCCESS, payload: assets }); +export const pageAssetsError = error => ({ + type: PAGE_ASSETS_ERROR, + payload: error, +}); + // ================ Thunks ================ // export const fetchAppAssets = (assets, version) => (dispatch, getState, sdk) => { - dispatch(assetsRequested()); + dispatch(appAssetsRequested()); // If version is given fetch assets by the version, // otherwise default to "latest" alias @@ -68,7 +88,7 @@ export const fetchAppAssets = (assets, version) => (dispatch, getState, sdk) => return Promise.all(sdkAssets) .then(responses => { const version = responses[0]?.data?.meta?.version; - dispatch(assetsSuccess(assets, version)); + dispatch(appAssetsSuccess(assets, version)); // Returned value looks like this for a single asset with name: "translations": // { @@ -84,6 +104,53 @@ export const fetchAppAssets = (assets, version) => (dispatch, getState, sdk) => }) .catch(e => { log.error(e, 'app-asset-fetch-failed', { assets, version }); - dispatch(assetsError(storableError(e))); + dispatch(appAssetsError(storableError(e))); + }); +}; + +export const fetchPageAssets = (assets, hasFallback) => (dispatch, getState, sdk) => { + const version = getState()?.hostedAssets?.version; + if (typeof version === 'undefined') { + throw new Error( + 'App-wide assets were not fetched first. Asset version missing from Redux store.' + ); + } + + dispatch(pageAssetsRequested()); + + // If version is given fetch assets by the version, + // otherwise default to "latest" alias + const fetchAssets = version + ? assetPath => sdk.assetByVersion({ path: assetPath, version }) + : assetPath => sdk.assetByAlias({ path: assetPath, alias: 'latest' }); + + const assetEntries = Object.entries(assets); + const sdkAssets = assetEntries.map(([key, assetPath]) => fetchAssets(assetPath)); + + return Promise.all(sdkAssets) + .then(responses => { + // Returned value looks like this for a single asset with name: "about-page": + // { + // "about-page": { + // path: 'content/about-page.json', // an example path in Asset Delivery API + // data, // translation key & value pairs + // }, + // // etc. + // } + const pageAssets = assetEntries.reduce((collectedAssets, assetEntry, i) => { + const [name, path] = assetEntry; + const assetData = denormalizeAssetData(responses[i].data); + return { ...collectedAssets, [name]: { path, data: assetData } }; + }, {}); + dispatch(pageAssetsSuccess(pageAssets)); + return pageAssets; + }) + .catch(e => { + // If there's a fallback UI, something went wrong when fetching the "known asset" like landing-page.json. + // If there's no fallback UI created, we assume that the page URL was mistyped for 404 errors. + if (hasFallback || (!hasFallback && e.status === 404)) { + log.error(e, 'page-asset-fetch-failed', { assets, version }); + } + dispatch(pageAssetsError(storableError(e))); }); }; From d534f6498f231ab38bd533c5de9356010dfb72ed Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 15 Jul 2022 01:16:15 +0300 Subject: [PATCH 16/99] Add CMSPage container --- src/containers/CMSPage/CMSPage.duck.js | 8 +++ src/containers/CMSPage/CMSPage.js | 76 ++++++++++++++++++++++++++ src/containers/pageDataLoadingAPI.js | 4 ++ src/routeConfiguration.js | 7 +++ 4 files changed, 95 insertions(+) create mode 100644 src/containers/CMSPage/CMSPage.duck.js create mode 100644 src/containers/CMSPage/CMSPage.js diff --git a/src/containers/CMSPage/CMSPage.duck.js b/src/containers/CMSPage/CMSPage.duck.js new file mode 100644 index 000000000..51c33b76a --- /dev/null +++ b/src/containers/CMSPage/CMSPage.duck.js @@ -0,0 +1,8 @@ +import { fetchPageAssets } from '../../ducks/hostedAssets.duck'; + +export const loadData = (params, search) => dispatch => { + const pageId = params.pageId; + const pageAsset = { [pageId]: `content/pages/${pageId}.json` }; + const hasFallbackContent = false; + return dispatch(fetchPageAssets(pageAsset, hasFallbackContent)); +}; diff --git a/src/containers/CMSPage/CMSPage.js b/src/containers/CMSPage/CMSPage.js new file mode 100644 index 000000000..e39714af8 --- /dev/null +++ b/src/containers/CMSPage/CMSPage.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { bool, object } from 'prop-types'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; + +import NotFoundPage from '../../containers/NotFoundPage/NotFoundPage'; +import PageBuilder from '../../containers/PageBuilder/PageBuilder'; + +export const CMSPageComponent = props => { + const { params, pageAssetsData, inProgress, error } = props; + const pageId = params.pageId; + + if (!inProgress && error?.status === 404) { + return ; + } + + // Schema for search engines (helps them to understand what this page is about) + // http://schema.org + // We are using JSON-LD format + + //////////////////////////////////////////////////////////////// + // TODO title and description should come from hosted assets. // + //////////////////////////////////////////////////////////////// + + // schemaTitle is used for tag in addition to page schema for SEO + const schemaTitle = 'CMS page'; + // schemaDescription is used for different <meta> tags in addition to page schema for SEO + const schemaDescription = 'CMS page'; + const openGraphContentType = 'website'; + + // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas + // Read more about schema + // - https://schema.org/ + // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data + const pageSchemaForSEO = { + '@context': 'http://schema.org', + '@type': 'WebPage', + description: schemaDescription, + name: schemaTitle, + }; + + return ( + <PageBuilder + pageAssetsData={pageAssetsData?.[pageId]?.data} + title={schemaTitle} + description={schemaDescription} + schema={pageSchemaForSEO} + contentType={openGraphContentType} + inProgress={inProgress} + /> + ); +}; + +CMSPageComponent.propTypes = { + pageAssetsData: object, + inProgress: bool, +}; + +const mapStateToProps = state => { + const { pageAssetsData, inProgress, error } = state.hostedAssets || {}; + return { pageAssetsData, inProgress, error }; +}; + +// Note: it is important that the withRouter HOC is **outside** the +// connect HOC, otherwise React Router won't rerender any Route +// components since connect implements a shouldComponentUpdate +// lifecycle hook. +// +// See: https://github.com/ReactTraining/react-router/issues/4671 +const CMSPage = compose( + withRouter, + connect(mapStateToProps) +)(CMSPageComponent); + +export default CMSPage; diff --git a/src/containers/pageDataLoadingAPI.js b/src/containers/pageDataLoadingAPI.js index e86c3a248..a8143c2e0 100644 --- a/src/containers/pageDataLoadingAPI.js +++ b/src/containers/pageDataLoadingAPI.js @@ -2,6 +2,7 @@ * Export loadData calls from ducks modules of different containers */ import { setInitialValues as CheckoutPageInitialValues } from './CheckoutPage/CheckoutPage.duck'; +import { loadData as CMSPageLoader } from './CMSPage/CMSPage.duck'; import { loadData as ContactDetailsPageLoader } from './ContactDetailsPage/ContactDetailsPage.duck'; import { loadData as EditListingPageLoader } from './EditListingPage/EditListingPage.duck'; import { loadData as EmailVerificationPageLoader } from './EmailVerificationPage/EmailVerificationPage.duck'; @@ -22,6 +23,9 @@ const getPageDataLoadingAPI = () => { CheckoutPage: { setInitialValues: CheckoutPageInitialValues, }, + CMSPage: { + loadData: CMSPageLoader, + }, ContactDetailsPage: { loadData: ContactDetailsPageLoader, }, diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index 0ec258a10..459e589fa 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -13,6 +13,7 @@ const pageDataLoadingAPI = getPageDataLoadingAPI(); const AboutPage = loadable(() => import(/* webpackChunkName: "AboutPage" */ './containers/AboutPage/AboutPage')); const AuthenticationPage = loadable(() => import(/* webpackChunkName: "AuthenticationPage" */ './containers/AuthenticationPage/AuthenticationPage')); const CheckoutPage = loadable(() => import(/* webpackChunkName: "CheckoutPage" */ './containers/CheckoutPage/CheckoutPage')); +const CMSPage = loadable(() => import(/* webpackChunkName: "CMSPage" */ './containers/CMSPage/CMSPage')); const ContactDetailsPage = loadable(() => import(/* webpackChunkName: "ContactDetailsPage" */ './containers/ContactDetailsPage/ContactDetailsPage')); const EditListingPage = loadable(() => import(/* webpackChunkName: "EditListingPage" */ './containers/EditListingPage/EditListingPage')); const EmailVerificationPage = loadable(() => import(/* webpackChunkName: "EmailVerificationPage" */ './containers/EmailVerificationPage/EmailVerificationPage')); @@ -63,6 +64,12 @@ const routeConfiguration = () => { name: 'LandingPage', component: LandingPage, }, + { + path: '/p/:pageId', + name: 'CMSPage', + component: CMSPage, + loadData: pageDataLoadingAPI.CMSPage.loadData, + }, { path: '/about', name: 'AboutPage', From fa2e21d590f040ac0f9f74b6534f7624463dcf91 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Fri, 15 Jul 2022 01:18:41 +0300 Subject: [PATCH 17/99] Update LandingPage container to start using PageBuilder Include initial version of fallback page/asset app.test: update to work with new LandingPage setup --- src/app.test.js | 17 ++- src/containers/LandingPage/FallbackPage.js | 91 ++++++++++++++ .../LandingPage/LandingPage.duck.js | 7 ++ .../LandingPage/LandingPage.example.js | 25 ++++ src/containers/LandingPage/LandingPage.js | 113 +++++++---------- .../LandingPage/LandingPage.module.css | 115 ------------------ .../LandingPage/LandingPage.test.js | 18 +-- .../__snapshots__/LandingPage.test.js.snap | 93 +++----------- src/containers/pageDataLoadingAPI.js | 4 + src/routeConfiguration.js | 1 + 10 files changed, 210 insertions(+), 274 deletions(-) create mode 100644 src/containers/LandingPage/FallbackPage.js create mode 100644 src/containers/LandingPage/LandingPage.duck.js create mode 100644 src/containers/LandingPage/LandingPage.example.js delete mode 100644 src/containers/LandingPage/LandingPage.module.css diff --git a/src/app.test.js b/src/app.test.js index 4229f3fea..0edac8582 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -14,9 +14,22 @@ afterAll(() => { }); describe('Application - JSDOM environment', () => { - it('renders in the client without crashing', () => { + it('renders the LandingPage without crashing', () => { window.google = { maps: {} }; - const store = configureStore(); + + // LandingPage gets rendered and it calls hostedAsset > fetchPageAssets > sdk.assetByVersion + const pageData = { + data: { + sections: [], + _schema: './schema.json', + }, + meta: { + version: 'bCsMYVYVawc8SMPzZWJpiw', + }, + }; + const resolvePageAssetCall = () => Promise.resolve(pageData); + const fakeSdk = { assetByVersion: resolvePageAssetCall, assetByAlias: resolvePageAssetCall }; + const store = configureStore({}, fakeSdk); const div = document.createElement('div'); ReactDOM.render(<ClientApp store={store} />, div); delete window.google; diff --git a/src/containers/LandingPage/FallbackPage.js b/src/containers/LandingPage/FallbackPage.js new file mode 100644 index 000000000..965fb47bb --- /dev/null +++ b/src/containers/LandingPage/FallbackPage.js @@ -0,0 +1,91 @@ +import React from 'react'; + +import PageBuilder from '../PageBuilder/PageBuilder'; + +// Create fallback content (array of sections) in page asset format: +export const fallbackSections = { + sections: [ + { + sectionType: 'features', + sectionId: 'hero', + background: { type: 'hexColor', color: '#ffff00' }, + // backgroundImage: { + // type: 'image', + // alt: 'Background image', + // image: { + // id: 'image', + // type: 'imageAsset', + // attributes: { + // variants: { + // square1x: { + // url: `https://picsum.photos/400/400`, + // width: 400, + // height: 400, + // }, + // square2x: { + // url: `https://picsum.photos/800/800`, + // width: 800, + // height: 800, + // }, + // }, + // }, + // }, + // }, + blocks: [ + { + blockType: 'default-block', + blockId: 'hero-content', + media: { + type: 'image', + alt: 'First image', + image: { + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/400/400`, + width: 400, + height: 400, + }, + square2x: { + url: `https://picsum.photos/800/800`, + width: 800, + height: 800, + }, + }, + }, + }, + }, + title: { type: 'heading1', content: 'My marketplace' }, + text: { + type: 'markdown', + content: + '### My unique marketplace for booking listings\n### You can also list your services here!', + }, + callToAction: { + type: 'internalButtonLink', + href: '/s', + label: 'Browse marketplace', + }, + }, + ], + }, + ], +}; + +// This is the fallback page, in case there's no Privacy Policy asset defined in Console. +const FallbackPage = props => { + const { title, description, schema, contentType } = props; + return ( + <PageBuilder + pageAssetsData={fallbackSections} + title={title} + description={description} + schema={schema} + contentType={contentType} + /> + ); +}; + +export default FallbackPage; diff --git a/src/containers/LandingPage/LandingPage.duck.js b/src/containers/LandingPage/LandingPage.duck.js new file mode 100644 index 000000000..e254de541 --- /dev/null +++ b/src/containers/LandingPage/LandingPage.duck.js @@ -0,0 +1,7 @@ +import { fetchPageAssets } from '../../ducks/hostedAssets.duck'; +export const ASSET_NAME = 'landing-page'; + +export const loadData = (params, search) => dispatch => { + const pageAsset = { landingPage: `content/pages/${ASSET_NAME}.json` }; + return dispatch(fetchPageAssets(pageAsset, true)); +}; diff --git a/src/containers/LandingPage/LandingPage.example.js b/src/containers/LandingPage/LandingPage.example.js new file mode 100644 index 000000000..606f74616 --- /dev/null +++ b/src/containers/LandingPage/LandingPage.example.js @@ -0,0 +1,25 @@ +import React from 'react'; +import FallbackPage from './FallbackPage.js'; + +const pageSchemaForSEO = { + '@context': 'http://schema.org', + '@type': 'WebPage', + description: 'schemaDescription', + name: 'schemaTitle', +}; + +const FallbackPageComponent = () => ( + <FallbackPage + title="title" + description="description" + pageSchemaForSEO={pageSchemaForSEO} + openGraphContentType="website" + /> +); + +export const FallbackPageExample = { + component: FallbackPageComponent, + props: {}, + group: 'PageBuilder', + rawOnly: true, +}; diff --git a/src/containers/LandingPage/LandingPage.js b/src/containers/LandingPage/LandingPage.js index b8064dd45..dd2fd9f76 100644 --- a/src/containers/LandingPage/LandingPage.js +++ b/src/containers/LandingPage/LandingPage.js @@ -1,104 +1,83 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import { bool, object } from 'prop-types'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import { injectIntl, intlShape } from '../../util/reactIntl'; -import { isScrollingDisabled } from '../../ducks/UI.duck'; + import config from '../../config'; -import { - Page, - SectionHero, - SectionHowItWorks, - SectionLocations, - LayoutSingleColumn, - LayoutWrapperTopbar, - LayoutWrapperMain, - LayoutWrapperFooter, - Footer, -} from '../../components'; -import { TopbarContainer } from '../../containers'; +import { injectIntl, intlShape } from '../../util/reactIntl'; + +import PageBuilder from '../../containers/PageBuilder/PageBuilder'; import facebookImage from '../../assets/saunatimeFacebook-1200x630.jpg'; import twitterImage from '../../assets/saunatimeTwitter-600x314.jpg'; -import css from './LandingPage.module.css'; + +import FallbackPage from './FallbackPage'; +import { ASSET_NAME } from './LandingPage.duck'; export const LandingPageComponent = props => { - const { history, intl, location, scrollingDisabled } = props; + const { intl, pageAssetsData, inProgress } = props; // Schema for search engines (helps them to understand what this page is about) // http://schema.org // We are using JSON-LD format const siteTitle = config.siteTitle; + // schemaTitle is used for <title> tag in addition to page schema for SEO const schemaTitle = intl.formatMessage({ id: 'LandingPage.schemaTitle' }, { siteTitle }); + // schemaDescription is used for different <meta> tags in addition to page schema for SEO const schemaDescription = intl.formatMessage({ id: 'LandingPage.schemaDescription' }); const schemaImage = `${config.canonicalRootURL}${facebookImage}`; + const openGraphContentType = 'website'; + + // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas + // Read more about schema + // - https://schema.org/ + // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data + const pageSchemaForSEO = { + '@context': 'http://schema.org', + '@type': 'WebPage', + description: schemaDescription, + name: schemaTitle, + image: [schemaImage], + }; + + // Convert kebab-case to camelCase: my-page-asset > myPageAsset + const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); return ( - <Page - className={css.root} - scrollingDisabled={scrollingDisabled} - contentType="website" - description={schemaDescription} + <PageBuilder + pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} title={schemaTitle} + description={schemaDescription} + schema={pageSchemaForSEO} + contentType={openGraphContentType} + inProgress={inProgress} + fallbackPage={ + <FallbackPage + title={schemaTitle} + description={schemaDescription} + schema={pageSchemaForSEO} + contentType={openGraphContentType} + /> + } facebookImages={[{ url: facebookImage, width: 1200, height: 630 }]} twitterImages={[ { url: `${config.canonicalRootURL}${twitterImage}`, width: 600, height: 314 }, ]} - schema={{ - '@context': 'http://schema.org', - '@type': 'WebPage', - description: schemaDescription, - name: schemaTitle, - image: [schemaImage], - }} - > - <LayoutSingleColumn> - <LayoutWrapperTopbar> - <TopbarContainer /> - </LayoutWrapperTopbar> - <LayoutWrapperMain> - <div className={css.heroContainer}> - <SectionHero className={css.hero} history={history} location={location} /> - </div> - <ul className={css.sections}> - <li className={css.section}> - <div className={css.sectionContentFirstChild}> - <SectionLocations /> - </div> - </li> - <li className={css.section}> - <div className={css.sectionContent}> - <SectionHowItWorks /> - </div> - </li> - </ul> - </LayoutWrapperMain> - <LayoutWrapperFooter> - <Footer /> - </LayoutWrapperFooter> - </LayoutSingleColumn> - </Page> + /> ); }; -const { bool, object } = PropTypes; - LandingPageComponent.propTypes = { - scrollingDisabled: bool.isRequired, - - // from withRouter - history: object.isRequired, - location: object.isRequired, - // from injectIntl intl: intlShape.isRequired, + pageAssetsData: object, + inProgress: bool, }; const mapStateToProps = state => { - return { - scrollingDisabled: isScrollingDisabled(state), - }; + const { pageAssetsData, inProgress } = state.hostedAssets || {}; + return { pageAssetsData, inProgress }; }; // Note: it is important that the withRouter HOC is **outside** the diff --git a/src/containers/LandingPage/LandingPage.module.css b/src/containers/LandingPage/LandingPage.module.css deleted file mode 100644 index fc6ba9080..000000000 --- a/src/containers/LandingPage/LandingPage.module.css +++ /dev/null @@ -1,115 +0,0 @@ -@import '../../styles/customMediaQueries.css'; - -.root { -} - -/* heroContainer gives the height for SectionHero */ -/* Safari has a bug with vw padding inside flexbox. Therefore we need an extra div (container) */ -/* If you've lot of content for the hero, multiple lines of text, make sure to adjust min-heights for each media breakpoint accordingly */ -.heroContainer { - display: flex; - flex-direction: column; - min-height: 300px; - height: 67.5vh; - max-height: 600px; - padding: 0; - - @media (--viewportMedium) { - min-height: 500px; - height: 70vh; - max-height: none; - } - - @media (--viewportLarge) { - max-height: 800px; - min-height: 600px; - height: calc(70vh - var(--topbarHeightDesktop)); - } -} - -.hero { - flex-grow: 1; - justify-content: flex-end; - padding-bottom: 32px; - - @media (--viewportMedium) { - padding-bottom: 83px; - } - - @media (--viewportLarge) { - justify-content: center; - padding-top: 60px; - } -} - -.sections { - margin: 0; - padding-top: 1px; -} - -.section { - overflow: auto; -} - -/* Square corners for the last section if it's even */ -.section:nth-of-type(2n):last-of-type { - @media (--viewportMedium) { - border-radius: 4px 4px 0 0; - } -} - -/* Every other section has a light background */ -.section:nth-of-type(2n) { - background-color: var(--matterColorLight); - @media (--viewportMedium) { - border-radius: 4px; - } -} - -.sectionContent { - margin: var(--LandingPage_sectionMarginTop) 24px 51px 24px; - - @media (--viewportMedium) { - max-width: 100%; - margin: var(--LandingPage_sectionMarginTopMedium) 24px 60px 24px; - } - - @media (--viewportLarge) { - max-width: 1128px; - padding: 0 36px 0 36px; - margin: var(--LandingPage_sectionMarginTopLarge) auto 93px auto; - } - - @media (--viewportXLarge) { - max-width: 1056px; - padding: 0; - } -} - -.sectionContentFirstChild { - composes: sectionContent; - margin-top: 3vh; -} - -/* A bar on top of light sections */ -.section:nth-of-type(2n) .sectionContent::before { - background: var(--marketplaceColor); - content: ''; - display: block; - width: 109px; - height: 6px; - - /* Place the bar on top of .sectionContent top margin */ - position: relative; - top: calc(-1 * var(--LandingPage_sectionMarginTop)); - - @media (--viewportMedium) { - width: 192px; - height: 8px; - top: calc(-1 * var(--LandingPage_sectionMarginTopMedium)); - } - - @media (--viewportLarge) { - top: calc(-1 * var(--LandingPage_sectionMarginTopLarge)); - } -} diff --git a/src/containers/LandingPage/LandingPage.test.js b/src/containers/LandingPage/LandingPage.test.js index 010a02ef7..51c37a355 100644 --- a/src/containers/LandingPage/LandingPage.test.js +++ b/src/containers/LandingPage/LandingPage.test.js @@ -3,25 +3,9 @@ import { fakeIntl } from '../../util/test-data'; import { renderShallow } from '../../util/test-helpers'; import { LandingPageComponent } from './LandingPage'; -const noop = () => null; - describe('LandingPage', () => { it('matches snapshot', () => { - const tree = renderShallow( - <LandingPageComponent - history={{ push: noop }} - location={{ search: '' }} - scrollingDisabled={false} - authInProgress={false} - currentUserHasListings={false} - intl={fakeIntl} - isAuthenticated={false} - onLogout={noop} - onManageDisableScrolling={noop} - sendVerificationEmailInProgress={false} - onResendVerificationEmail={noop} - /> - ); + const tree = renderShallow(<LandingPageComponent intl={fakeIntl} pageAssetsData={{}} />); expect(tree).toMatchSnapshot(); }); }); diff --git a/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap b/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap index 17536ea8c..9b44309e5 100644 --- a/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap +++ b/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap @@ -1,8 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`LandingPage matches snapshot 1`] = ` -<Page - className="root" +<PageBuilder contentType="website" description="LandingPage.schemaDescription" facebookImages={ @@ -14,6 +13,24 @@ exports[`LandingPage matches snapshot 1`] = ` }, ] } + fallbackPage={ + <FallbackPage + contentType="website" + description="LandingPage.schemaDescription" + schema={ + Object { + "@context": "http://schema.org", + "@type": "WebPage", + "description": "LandingPage.schemaDescription", + "image": Array [ + "http://localhost:3000saunatimeFacebook-1200x630.jpg", + ], + "name": "LandingPage.schemaTitle", + } + } + title="LandingPage.schemaTitle" + /> + } schema={ Object { "@context": "http://schema.org", @@ -25,7 +42,6 @@ exports[`LandingPage matches snapshot 1`] = ` "name": "LandingPage.schemaTitle", } } - scrollingDisabled={false} title="LandingPage.schemaTitle" twitterImages={ Array [ @@ -36,74 +52,5 @@ exports[`LandingPage matches snapshot 1`] = ` }, ] } -> - <LayoutSingleColumn - className={null} - rootClassName={null} - > - <LayoutWrapperTopbar - className={null} - rootClassName={null} - > - <withRouter(Connect(TopbarContainerComponent)) /> - </LayoutWrapperTopbar> - <LayoutWrapperMain - className={null} - rootClassName={null} - > - <div - className="heroContainer" - > - <SectionHero - className="hero" - history={ - Object { - "push": [Function], - } - } - location={ - Object { - "search": "", - } - } - rootClassName={null} - /> - </div> - <ul - className="sections" - > - <li - className="section" - > - <div - className="sectionContentFirstChild" - > - <SectionLocations - className={null} - rootClassName={null} - /> - </div> - </li> - <li - className="section" - > - <div - className="sectionContent" - > - <SectionHowItWorks - className={null} - rootClassName={null} - /> - </div> - </li> - </ul> - </LayoutWrapperMain> - <LayoutWrapperFooter - className={null} - rootClassName={null} - > - <injectIntl(Footer) /> - </LayoutWrapperFooter> - </LayoutSingleColumn> -</Page> +/> `; diff --git a/src/containers/pageDataLoadingAPI.js b/src/containers/pageDataLoadingAPI.js index a8143c2e0..af56b07df 100644 --- a/src/containers/pageDataLoadingAPI.js +++ b/src/containers/pageDataLoadingAPI.js @@ -1,6 +1,7 @@ /** * Export loadData calls from ducks modules of different containers */ +import { loadData as LandingPageLoader } from './LandingPage/LandingPage.duck'; import { setInitialValues as CheckoutPageInitialValues } from './CheckoutPage/CheckoutPage.duck'; import { loadData as CMSPageLoader } from './CMSPage/CMSPage.duck'; import { loadData as ContactDetailsPageLoader } from './ContactDetailsPage/ContactDetailsPage.duck'; @@ -20,6 +21,9 @@ import { const getPageDataLoadingAPI = () => { return { + LandingPage: { + loadData: LandingPageLoader, + }, CheckoutPage: { setInitialValues: CheckoutPageInitialValues, }, diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index 459e589fa..ab4491bdb 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -63,6 +63,7 @@ const routeConfiguration = () => { path: '/', name: 'LandingPage', component: LandingPage, + loadData: pageDataLoadingAPI.LandingPage.loadData, }, { path: '/p/:pageId', From fb2067fa73f74d171787744e9a13e0d2f72bcc9c Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 18 Jul 2022 13:52:32 +0300 Subject: [PATCH 18/99] Change lorempixel.com to picsum.photos --- server/csp.js | 2 +- src/components/Avatar/Avatar.example.js | 4 +-- .../SectionThumbnailLinks.example.js | 30 +++++++++---------- src/components/UserCard/UserCard.example.js | 8 ++--- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/server/csp.js b/server/csp.js index 334a91280..47fc51818 100644 --- a/server/csp.js +++ b/server/csp.js @@ -50,7 +50,7 @@ const defaultDirectives = { 'sharetribe.imgix.net', // Safari 9.1 didn't recognize asterisk rule. // Styleguide placeholder images - 'lorempixel.com', + 'picsum.photos', 'via.placeholder.com', 'api.mapbox.com', diff --git a/src/components/Avatar/Avatar.example.js b/src/components/Avatar/Avatar.example.js index e1dd9b393..ed07caaaf 100644 --- a/src/components/Avatar/Avatar.example.js +++ b/src/components/Avatar/Avatar.example.js @@ -46,13 +46,13 @@ const userWithProfileImage = { name: 'square-small', width: 240, height: 240, - url: 'https://lorempixel.com/240/240/people/', + url: 'https://picsum.photos/240/240/', }, 'square-small2x': { name: 'square-small2x', width: 480, height: 480, - url: 'https://lorempixel.com/480/480/people/', + url: 'https://picsum.photos/480/480/', }, }, }, diff --git a/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js b/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js index 996a8b2ec..6d5e299ee 100644 --- a/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js +++ b/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js @@ -8,13 +8,13 @@ export const TwoNamedLinksWithHeadings = { linksPerRow: 2, links: [ { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?1' } }, text: 'Link 1', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?2' } }, text: 'Link 2', @@ -32,19 +32,19 @@ export const ThreeExternalLinksWithHeadings = { linksPerRow: 3, links: [ { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'ExternalLink', href: 'http://example.com/1' }, text: 'Link 1', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'ExternalLink', href: 'http://example.com/2' }, text: 'Link 2', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'ExternalLink', href: 'http://example.com/3' }, text: 'Link 3', @@ -62,25 +62,25 @@ export const FourLinks = { linksPerRow: 2, links: [ { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?1' } }, text: 'Link 1 with quite a long text that tests how the items below align', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?2' } }, text: 'Link 2', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?3' } }, text: 'Link 3', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?4' } }, text: 'Link 4', @@ -96,38 +96,38 @@ export const SixLinks = { linksPerRow: 3, links: [ { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?1' } }, text: 'Link 1', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?2' } }, searchQuery: '?2', text: 'Link 2', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?3' } }, text: 'Link 3', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?4' } }, text: 'Link 4', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?5' } }, text: 'Link 5', }, { - imageUrl: 'https://lorempixel.com/648/448/', + imageUrl: 'https://picsum.photos/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?6' } }, text: 'Link 6', diff --git a/src/components/UserCard/UserCard.example.js b/src/components/UserCard/UserCard.example.js index d52cceb7b..e2f7cc482 100644 --- a/src/components/UserCard/UserCard.example.js +++ b/src/components/UserCard/UserCard.example.js @@ -56,13 +56,13 @@ export const WithProfileImageAndBioCurrentUser = { name: 'square-small', width: 240, height: 240, - url: 'https://lorempixel.com/240/240/people/', + url: 'https://picsum.photos/240/240/', }, 'square-small2x': { name: 'square-small2x', width: 480, height: 480, - url: 'https://lorempixel.com/480/480/people/', + url: 'https://picsum.photos480/480/', }, }, }, @@ -99,13 +99,13 @@ export const WithProfileImageAndBio = { name: 'square-small', width: 240, height: 240, - url: 'https://lorempixel.com/240/240/people/', + url: 'https://picsum.photos/240/240/', }, 'square-small2x': { name: 'square-small2x', width: 480, height: 480, - url: 'https://lorempixel.com/480/480/people/', + url: 'https://picsum.photos/480/480/', }, }, }, From 5024a104a0e7917d9ff77adbe1d49f64c6613ea2 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Fri, 15 Jul 2022 01:20:17 +0300 Subject: [PATCH 19/99] Add Styleguide examples --- src/components/Footer/Footer.module.css | 1 + .../StyleguidePage/StyleguidePage.module.css | 1 + src/examples.js | 10 ++++++++++ 3 files changed, 12 insertions(+) diff --git a/src/components/Footer/Footer.module.css b/src/components/Footer/Footer.module.css index 8f688aae2..859a4e4ee 100644 --- a/src/components/Footer/Footer.module.css +++ b/src/components/Footer/Footer.module.css @@ -1,6 +1,7 @@ @import '../../styles/customMediaQueries.css'; .root { + position: relative; border-top-style: solid; border-top-width: 1px; border-top-color: var(--matterColorNegative); diff --git a/src/containers/StyleguidePage/StyleguidePage.module.css b/src/containers/StyleguidePage/StyleguidePage.module.css index 6fc9028b6..7cae404c6 100644 --- a/src/containers/StyleguidePage/StyleguidePage.module.css +++ b/src/containers/StyleguidePage/StyleguidePage.module.css @@ -39,6 +39,7 @@ @media (--viewportMedium) { padding: 48px 16px 0 16px; + width: calc(100vw - 300px); } } diff --git a/src/examples.js b/src/examples.js index 1dfe59137..bdd607cef 100644 --- a/src/examples.js +++ b/src/examples.js @@ -95,6 +95,11 @@ import * as StripePaymentForm from './forms/StripePaymentForm/StripePaymentForm. // containers import * as Colors from './containers/StyleguidePage/Colors.example'; import * as Typography from './containers/StyleguidePage/Typography.example'; +import * as CMSSections from './containers/PageBuilder/SectionBuilder/SectionBuilder.example'; +import * as Markdown from './containers/PageBuilder/Markdown.example'; +import * as LayoutComposer from './containers/PageBuilder/LayoutComposer/LayoutComposer.example'; +import * as PageBuilder from './containers/PageBuilder/PageBuilder.example'; +import * as LandingPage from './containers/LandingPage/LandingPage.example'; export { ActivityFeed, @@ -107,6 +112,7 @@ export { BookingPanel, Button, Colors, + CMSSections, EditListingAvailabilityForm, EditListingDescriptionForm, EditListingFeaturesForm, @@ -156,16 +162,20 @@ export { IconSuccess, ImageCarousel, KeywordFilter, + LandingPage, + LayoutComposer, ListingCard, LocationAutocompleteInput, LoginForm, ManageListingCard, Map, + Markdown, Menu, Modal, ModalInMobile, NamedLink, OutsideClickHandler, + PageBuilder, PaginationLinks, PasswordRecoveryForm, PasswordResetForm, From 965858168a8e7d4a6d565cbe2c363b38627aa0c1 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 15:01:39 +0300 Subject: [PATCH 20/99] SectionBuilder: add isInsideContainer prop for cases where sections are shown inside a wrapper (like Modal). --- .../SectionBuilder/SectionArticle/SectionArticle.js | 8 ++++++-- .../SectionArticle/SectionArticle.module.css | 5 +++++ .../PageBuilder/SectionBuilder/SectionBuilder.js | 8 ++++++-- .../SectionBuilder/SectionColumns/SectionColumns.js | 12 ++++++++++-- .../SectionColumns/SectionColumns.module.css | 5 +++++ .../SectionFeatures/SectionFeatures.js | 8 ++++++-- .../SectionFeatures/SectionFeatures.module.css | 5 +++++ 7 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index 3988fd3d4..1dc2c6c55 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -1,5 +1,6 @@ import React from 'react'; -import { arrayOf, func, node, object, oneOf, shape, string } from 'prop-types'; +import { arrayOf, bool, func, node, object, oneOf, shape, string } from 'prop-types'; +import classNames from 'classnames'; import Field, { validProps } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; @@ -21,6 +22,7 @@ const SectionArticle = props => { backgroundImage, callToAction, blocks, + isInsideContainer, options, } = props; @@ -50,7 +52,7 @@ const SectionArticle = props => { <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> </header> {hasBlocks ? ( - <div className={css.articleMain}> + <div className={classNames(css.articleMain, { [css.noSidePaddings]: isInsideContainer })}> <BlockBuilder blocks={blocks} options={options} /> </div> ) : null} @@ -80,6 +82,7 @@ SectionArticle.defaultProps = { backgroundImage: null, callToAction: null, blocks: [], + isInsideContainer: false, options: null, }; @@ -99,6 +102,7 @@ SectionArticle.propTypes = { backgroundImage: object, callToAction: object, blocks: arrayOf(propTypeBlock), + isInsideContainer: bool, options: propTypeOption, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css index d98f1fd00..d47858ea8 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css @@ -6,3 +6,8 @@ margin: 0 auto; padding: 32px; } + +.noSidePaddings { + padding-left: 0; + padding-right: 0; +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index 0629535e8..a8734d803 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -1,5 +1,5 @@ import React from 'react'; -import { arrayOf, func, node, oneOf, shape, string } from 'prop-types'; +import { arrayOf, bool, func, node, oneOf, shape, string } from 'prop-types'; import classNames from 'classnames'; // Section components @@ -42,7 +42,7 @@ const defaultSectionComponents = { const SectionBuilder = props => { const { sections, options } = props; - const { sectionComponents = {}, ...otherOption } = options || {}; + const { sectionComponents = {}, isInsideContainer, ...otherOption } = options || {}; // If there's no sections, we can't render the correct section component if (!sections || sections.length === 0) { @@ -70,6 +70,7 @@ const SectionBuilder = props => { key={section.sectionId} className={classes} defaultClasses={DEFAULT_CLASSES} + isInsideContainer={isInsideContainer} options={otherOption} {...section} /> @@ -95,6 +96,9 @@ const propTypeOption = shape({ fieldComponents: shape({ component: node, pickValidProps: func }), blockComponents: shape({ component: node }), sectionComponents: shape({ component: node }), + // isInsideContainer boolean means that the section is not taking + // the full viewport width but is run inside some wrapper. + isInsideContainer: bool, }); SectionBuilder.defaultProps = { diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js index 7e62ec108..515babdf7 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js @@ -1,5 +1,6 @@ import React from 'react'; -import { arrayOf, func, node, number, object, oneOf, shape, string } from 'prop-types'; +import { arrayOf, bool, func, node, number, object, oneOf, shape, string } from 'prop-types'; +import classNames from 'classnames'; import Field, { validProps } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; @@ -38,6 +39,7 @@ const SectionColumns = props => { backgroundImage, callToAction, blocks, + isInsideContainer, options, } = props; @@ -67,7 +69,11 @@ const SectionColumns = props => { <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> </header> {hasBlocks ? ( - <div className={getColumnCSS(numColumns)}> + <div + className={classNames(getColumnCSS(numColumns), { + [css.noSidePaddings]: isInsideContainer, + })} + > <BlockBuilder ctaButtonClass={defaultClasses.ctaButton} blocks={blocks} @@ -103,6 +109,7 @@ SectionColumns.defaultProps = { backgroundImage: null, callToAction: null, blocks: [], + isInsideContainer: false, options: null, }; @@ -123,6 +130,7 @@ SectionColumns.propTypes = { backgroundImage: object, callToAction: object, blocks: arrayOf(propTypeBlock), + isInsideContainer: bool, options: propTypeOption, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css index d6be3955f..e66ce06ac 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.module.css @@ -31,3 +31,8 @@ grid-template-columns: repeat(4, calc((100% - 3 * 32px) / 4)); } } + +.noSidePaddings { + padding-left: 0; + padding-right: 0; +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js index e83f2844e..8ab404f7f 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -1,5 +1,6 @@ import React from 'react'; -import { arrayOf, func, node, object, oneOf, shape, string } from 'prop-types'; +import { arrayOf, bool, func, node, object, oneOf, shape, string } from 'prop-types'; +import classNames from 'classnames'; import Field, { validProps } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; @@ -25,6 +26,7 @@ const SectionFeatures = props => { backgroundImage, callToAction, blocks, + isInsideContainer, options, } = props; @@ -54,7 +56,7 @@ const SectionFeatures = props => { <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> </header> {hasBlocks ? ( - <div className={css.featuresMain}> + <div className={classNames(css.featuresMain, { [css.noSidePaddings]: isInsideContainer })}> <BlockBuilder rootClassName={css.block} ctaButtonClass={defaultClasses.ctaButton} @@ -89,6 +91,7 @@ SectionFeatures.defaultProps = { backgroundImage: null, callToAction: null, blocks: [], + isInsideContainer: false, options: null, }; @@ -108,6 +111,7 @@ SectionFeatures.propTypes = { backgroundImage: object, callToAction: object, blocks: arrayOf(propTypeBlock), + isInsideContainer: bool, options: propTypeOption, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css index 3a141d96e..14c190bad 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.module.css @@ -31,3 +31,8 @@ } } } + +.noSidePaddings { + padding-left: 0; + padding-right: 0; +} From b04e96fe850a0639b88a9f0ce117dd3d56149f5b Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 14:20:07 +0300 Subject: [PATCH 21/99] SectionBuilder exported from PageBuilder --- src/containers/PageBuilder/PageBuilder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/PageBuilder/PageBuilder.js b/src/containers/PageBuilder/PageBuilder.js index 5f75db3d3..1566a513c 100644 --- a/src/containers/PageBuilder/PageBuilder.js +++ b/src/containers/PageBuilder/PageBuilder.js @@ -71,6 +71,6 @@ const PageBuilder = props => { ); }; -export { StaticPage }; +export { StaticPage, SectionBuilder }; export default PageBuilder; From 913a7532b0304f73a8de8e4032b30be9a94d656f Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 14:19:29 +0300 Subject: [PATCH 22/99] TermsOfServicePage is built with PageBuilder and Asset Delivery API. It defaults to fallback page --- .../TermsOfService/TermsOfService.module.css | 39 ----- src/components/index.js | 1 - .../TermsOfServicePage/FallbackPage.js | 51 ++++++ .../TermsOfServicePage.duck.js | 7 + .../TermsOfServicePage/TermsOfServicePage.js | 151 +++++++++++------- .../TermsOfServicePage.module.css | 15 -- src/containers/pageDataLoadingAPI.js | 4 + src/routeConfiguration.js | 1 + src/translations/de.json | 4 +- src/translations/en.json | 4 +- src/translations/es.json | 4 +- src/translations/fr.json | 4 +- 12 files changed, 158 insertions(+), 127 deletions(-) delete mode 100644 src/components/TermsOfService/TermsOfService.module.css create mode 100644 src/containers/TermsOfServicePage/FallbackPage.js create mode 100644 src/containers/TermsOfServicePage/TermsOfServicePage.duck.js delete mode 100644 src/containers/TermsOfServicePage/TermsOfServicePage.module.css diff --git a/src/components/TermsOfService/TermsOfService.module.css b/src/components/TermsOfService/TermsOfService.module.css deleted file mode 100644 index 16e59ac55..000000000 --- a/src/components/TermsOfService/TermsOfService.module.css +++ /dev/null @@ -1,39 +0,0 @@ -@import '../../styles/customMediaQueries.css'; - -.root { - & p { - font-weight: var(--fontWeightMedium); - font-size: 15px; - line-height: 24px; - letter-spacing: 0; - /* margin-top + n * line-height + margin-bottom => x * 6px */ - margin-top: 12px; - margin-bottom: 12px; - - @media (--viewportMedium) { - font-weight: var(--fontWeightMedium); - /* margin-top + n * line-height + margin-bottom => x * 8px */ - margin-top: 17px; - margin-bottom: 15px; - } - } - & h2 { - /* Adjust heading margins to work with the reduced body font size */ - margin: 29px 0 13px 0; - - @media (--viewportMedium) { - margin: 32px 0 0 0; - } - } -} - -.lastUpdated { - composes: marketplaceBodyFontStyles from global; - margin-top: 0; - margin-bottom: 55px; - - @media (--viewportMedium) { - margin-top: 0; - margin-bottom: 54px; - } -} diff --git a/src/components/index.js b/src/components/index.js index 9ebe5bf7e..ea4bde4f7 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -158,7 +158,6 @@ export {default as LoadableComponentErrorBoundary } from './LoadableComponentErr export { default as ModalMissingInformation } from './ModalMissingInformation/ModalMissingInformation'; export { default as ReviewModal } from './ReviewModal/ReviewModal'; export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy'; -export { default as TermsOfService } from './TermsOfService/TermsOfService'; export { default as EditListingAvailabilityPanel } from './EditListingAvailabilityPanel/EditListingAvailabilityPanel'; export { default as EditListingDescriptionPanel } from './EditListingDescriptionPanel/EditListingDescriptionPanel'; export { default as EditListingFeaturesPanel } from './EditListingFeaturesPanel/EditListingFeaturesPanel'; diff --git a/src/containers/TermsOfServicePage/FallbackPage.js b/src/containers/TermsOfServicePage/FallbackPage.js new file mode 100644 index 000000000..5665c1a22 --- /dev/null +++ b/src/containers/TermsOfServicePage/FallbackPage.js @@ -0,0 +1,51 @@ +import React from 'react'; + +import PageBuilder from '../PageBuilder/PageBuilder'; + +// NOTE: You could add the actual Terms of Service here as a fallback +// instead of showing this error message. +const fallbackTerms = ` +# An error occurred +The web app couldn\'t reach the backend to fetch the Term of Service page. + +## Possible actions +Please refresh the page and, if that doesn't help, contact the marketplace administrators. +`; + +// Create fallback content (array of sections) in page asset format: +export const fallbackSections = { + sections: [ + { + sectionType: 'article', + sectionId: 'terms', + background: { type: 'hexColor', color: '#ffffff' }, + title: { type: 'heading1', content: 'Terms of Service' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'hero-content', + text: { + type: 'markdown', + content: fallbackTerms, + }, + }, + ], + }, + ], +}; + +// This is the fallback page, in case there's no Terms of Service asset defined in Console. +const FallbackPage = props => { + const { title, description, schema, contentType } = props; + return ( + <PageBuilder + pageAssetsData={fallbackSections} + title={title} + description={description} + schema={schema} + contentType={contentType} + /> + ); +}; + +export default FallbackPage; diff --git a/src/containers/TermsOfServicePage/TermsOfServicePage.duck.js b/src/containers/TermsOfServicePage/TermsOfServicePage.duck.js new file mode 100644 index 000000000..3de1a6c9d --- /dev/null +++ b/src/containers/TermsOfServicePage/TermsOfServicePage.duck.js @@ -0,0 +1,7 @@ +import { fetchPageAssets } from '../../ducks/hostedAssets.duck'; +export const ASSET_NAME = 'terms-of-service'; + +export const loadData = (params, search) => dispatch => { + const pageAsset = { termsOfServicePage: `content/pages/${ASSET_NAME}.json` }; + return dispatch(fetchPageAssets(pageAsset, true)); +}; diff --git a/src/containers/TermsOfServicePage/TermsOfServicePage.js b/src/containers/TermsOfServicePage/TermsOfServicePage.js index 313ca2358..b0f1b8040 100644 --- a/src/containers/TermsOfServicePage/TermsOfServicePage.js +++ b/src/containers/TermsOfServicePage/TermsOfServicePage.js @@ -1,91 +1,122 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import { bool, object } from 'prop-types'; import { compose } from 'redux'; import { connect } from 'react-redux'; -import { FormattedMessage, injectIntl, intlShape } from '../../util/reactIntl'; -import { isScrollingDisabled } from '../../ducks/UI.duck'; -import { TopbarContainer } from '../../containers'; -import { - Page, - LayoutSideNavigation, - LayoutWrapperMain, - LayoutWrapperSideNav, - LayoutWrapperTopbar, - LayoutWrapperFooter, - Footer, - TermsOfService, -} from '../../components'; +import { withRouter } from 'react-router-dom'; + import config from '../../config'; +import { injectIntl, intlShape } from '../../util/reactIntl'; + +import { H1 } from '../PageBuilder/Primitives/Heading'; +import PageBuilder, { SectionBuilder } from '../../containers/PageBuilder/PageBuilder'; + +import FallbackPage, { fallbackSections } from './FallbackPage'; +import { ASSET_NAME } from './TermsOfServicePage.duck'; + +// This "content-only" component can be used in modals etc. +const TermsOfServiceContent = props => { + const { inProgress, error, data } = props; + + if (inProgress) { + return null; + } -import css from './TermsOfServicePage.module.css'; + // We don't want to add h1 heading twice to the HTML (SEO issue). + // Modal's header is mapped as h2 + const hasContent = data => typeof data?.content === 'string'; + const exposeContentAsChildren = data => { + return hasContent(data) ? { children: data.content } : {}; + }; + const CustomHeading1 = props => <H1 as="h2" {...props} />; + + const hasData = error === null && data; + const sectionsData = hasData ? data : fallbackSections; + return ( + <SectionBuilder + {...sectionsData} + options={{ + fieldComponents: { + heading1: { component: CustomHeading1, pickValidProps: exposeContentAsChildren }, + }, + isInsideContainer: true, + }} + /> + ); +}; + +// Presentational component for TermsOfServicePage const TermsOfServicePageComponent = props => { - const { scrollingDisabled, intl } = props; - - const tabs = [ - { - text: intl.formatMessage({ id: 'TermsOfServicePage.privacyTabTitle' }), - selected: false, - linkProps: { - name: 'PrivacyPolicyPage', - }, - }, - { - text: intl.formatMessage({ id: 'TermsOfServicePage.tosTabTitle' }), - selected: true, - linkProps: { - name: 'TermsOfServicePage', - }, - }, - ]; + const { intl, pageAssetsData, inProgress } = props; + + // Schema for search engines (helps them to understand what this page is about) + // http://schema.org + // We are using JSON-LD format const siteTitle = config.siteTitle; + // schemaTitle is used for <title> tag in addition to page schema for SEO const schemaTitle = intl.formatMessage({ id: 'TermsOfServicePage.schemaTitle' }, { siteTitle }); - const schema = { + // schemaDescription is used for different <meta> tags in addition to page schema for SEO + const schemaDescription = intl.formatMessage({ id: 'TermsOfServicePage.schemaDescription' }); + const openGraphContentType = 'website'; + + // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas + // Read more about schema + // - https://schema.org/ + // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data + const pageSchemaForSEO = { '@context': 'http://schema.org', '@type': 'WebPage', + description: schemaDescription, name: schemaTitle, }; + + // Convert kebab-case to camelCase: my-page-asset > myPageAsset + const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); + return ( - <Page title={schemaTitle} scrollingDisabled={scrollingDisabled} schema={schema}> - <LayoutSideNavigation> - <LayoutWrapperTopbar> - <TopbarContainer currentPage="TermsOfServicePage" /> - </LayoutWrapperTopbar> - <LayoutWrapperSideNav tabs={tabs} /> - <LayoutWrapperMain> - <div className={css.content}> - <h1 className={css.heading}> - <FormattedMessage id="TermsOfServicePage.heading" /> - </h1> - <TermsOfService /> - </div> - </LayoutWrapperMain> - <LayoutWrapperFooter> - <Footer /> - </LayoutWrapperFooter> - </LayoutSideNavigation> - </Page> + <PageBuilder + pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} + title={schemaTitle} + description={schemaDescription} + schema={pageSchemaForSEO} + contentType={openGraphContentType} + inProgress={inProgress} + fallbackPage={ + <FallbackPage + title={schemaTitle} + description={schemaDescription} + schema={pageSchemaForSEO} + contentType={openGraphContentType} + /> + } + /> ); }; -const { bool } = PropTypes; - TermsOfServicePageComponent.propTypes = { - scrollingDisabled: bool.isRequired, - // from injectIntl intl: intlShape.isRequired, + pageAssetsData: object, + inProgress: bool, }; const mapStateToProps = state => { - return { - scrollingDisabled: isScrollingDisabled(state), - }; + const { pageAssetsData, inProgress } = state.hostedAssets || {}; + return { pageAssetsData, inProgress }; }; +// Note: it is important that the withRouter HOC is **outside** the +// connect HOC, otherwise React Router won't rerender any Route +// components since connect implements a shouldComponentUpdate +// lifecycle hook. +// +// See: https://github.com/ReactTraining/react-router/issues/4671 const TermsOfServicePage = compose( + withRouter, connect(mapStateToProps), injectIntl )(TermsOfServicePageComponent); +export { ASSET_NAME as TOS_ASSET_NAME, TermsOfServicePageComponent, TermsOfServiceContent }; + export default TermsOfServicePage; diff --git a/src/containers/TermsOfServicePage/TermsOfServicePage.module.css b/src/containers/TermsOfServicePage/TermsOfServicePage.module.css deleted file mode 100644 index 7e143c376..000000000 --- a/src/containers/TermsOfServicePage/TermsOfServicePage.module.css +++ /dev/null @@ -1,15 +0,0 @@ -@import '../../styles/customMediaQueries.css'; - -.heading { - margin: 5px 0 18px 0; - - @media (--viewportMedium) { - margin: 8px 0 24px 0; - } -} - -.content { - @media (--viewportLarge) { - max-width: 563px; - } -} diff --git a/src/containers/pageDataLoadingAPI.js b/src/containers/pageDataLoadingAPI.js index af56b07df..97c212ee0 100644 --- a/src/containers/pageDataLoadingAPI.js +++ b/src/containers/pageDataLoadingAPI.js @@ -14,6 +14,7 @@ import { loadData as PaymentMethodsPageLoader } from './PaymentMethodsPage/Payme import { loadData as ProfilePageLoader } from './ProfilePage/ProfilePage.duck'; import { loadData as SearchPageLoader } from './SearchPage/SearchPage.duck'; import { loadData as StripePayoutPageLoader } from './StripePayoutPage/StripePayoutPage.duck'; +import { loadData as TermsOfServicePageLoader } from './TermsOfServicePage/TermsOfServicePage.duck'; import { loadData as TransactionPageLoader, setInitialValues as TransactionPageInitialValues, @@ -60,6 +61,9 @@ const getPageDataLoadingAPI = () => { StripePayoutPage: { loadData: StripePayoutPageLoader, }, + TermsOfServicePage: { + loadData: TermsOfServicePageLoader, + }, TransactionPage: { loadData: TransactionPageLoader, setInitialValues: TransactionPageInitialValues, diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index ab4491bdb..d5ac3f4f1 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -295,6 +295,7 @@ const routeConfiguration = () => { path: '/terms-of-service', name: 'TermsOfServicePage', component: TermsOfServicePage, + loadData: pageDataLoadingAPI.TermsOfServicePage.loadData, }, { path: '/privacy-policy', diff --git a/src/translations/de.json b/src/translations/de.json index 456cc6369..26f1b74ee 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -946,10 +946,8 @@ "StripeConnectAccountStatusBox.verificationNeededText": "In order for you to receive payments you need to add few more details to your Stripe account to verify your account.", "StripeConnectAccountStatusBox.verificationNeededTitle": "Stripe needs more information", "StripeConnectAccountStatusBox.verificationSuccessTitle": "Your Stripe account is up to date!", - "TermsOfServicePage.heading": "Allgemeine Geschäftsbedingungen", - "TermsOfServicePage.privacyTabTitle": "Datenschutzbestimmungen", + "TermsOfServicePage.schemaDescription": "Terms of Service - rules and conditions under which the service is provided.", "TermsOfServicePage.schemaTitle": "Allgemeine Geschätfsbedingungen | {siteTitle}", - "TermsOfServicePage.tosTabTitle": "AGB", "Topbar.genericError": "Hoppla, etwas ist schiefgelaufen. Bitte prüfe deine Netzwerkverbindung und erneut versuchen.", "Topbar.logoIcon": "Zur Homepage gehen", "Topbar.menuIcon": "Menu öffnen", diff --git a/src/translations/en.json b/src/translations/en.json index de9c00ae5..edf1263bd 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -946,10 +946,8 @@ "StripeConnectAccountStatusBox.verificationNeededText": "In order for you to receive payments you need to add few more details to your Stripe account to verify your account.", "StripeConnectAccountStatusBox.verificationNeededTitle": "Stripe needs more information", "StripeConnectAccountStatusBox.verificationSuccessTitle": "Your Stripe account is up to date!", - "TermsOfServicePage.heading": "Terms of Service", - "TermsOfServicePage.privacyTabTitle": "Privacy Policy", + "TermsOfServicePage.schemaDescription": "Terms of Service - rules and conditions under which the service is provided.", "TermsOfServicePage.schemaTitle": "Terms of Service | {siteTitle}", - "TermsOfServicePage.tosTabTitle": "Terms of Service", "Topbar.genericError": "Oh no, something went wrong. Please check your network connection and try again.", "Topbar.logoIcon": "Go to homepage", "Topbar.menuIcon": "Open menu", diff --git a/src/translations/es.json b/src/translations/es.json index 0c0a6a2a1..12a88780d 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -946,10 +946,8 @@ "StripeConnectAccountStatusBox.verificationNeededText": "Para recibir pagos, necesitas añadir datos adicionales a tu cuenta de Stripe para verificar la cuenta.", "StripeConnectAccountStatusBox.verificationNeededTitle": "Stripe necesita más información", "StripeConnectAccountStatusBox.verificationSuccessTitle": "¡Tu cuenta de Stripe esta actualizada!", - "TermsOfServicePage.heading": "Términos y condiciones", - "TermsOfServicePage.privacyTabTitle": "Política de privacidad", + "TermsOfServicePage.schemaDescription": "Terms of Service - rules and conditions under which the service is provided.", "TermsOfServicePage.schemaTitle": "Términos y condiciones | {siteTitle}", - "TermsOfServicePage.tosTabTitle": "Términos y condiciones", "Topbar.genericError": "Algo ha salido mal. Por favor, comprueba tu conexión y vuelve a intentarlo.", "Topbar.logoIcon": "Ir a la página de inicio", "Topbar.menuIcon": "Abrir menú", diff --git a/src/translations/fr.json b/src/translations/fr.json index dbd9a5409..1bbf57807 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -946,10 +946,8 @@ "StripeConnectAccountStatusBox.verificationNeededText": "Pour recevoir des paiements, vous devez ajouter quelques détails supplémentaires à votre compte Stripe pour pouvoir le vérifier.", "StripeConnectAccountStatusBox.verificationNeededTitle": "Stripe demande des informations supplémentaires", "StripeConnectAccountStatusBox.verificationSuccessTitle": "Votre compte Stripe est à jour !", - "TermsOfServicePage.heading": "Conditions d'utilisation", - "TermsOfServicePage.privacyTabTitle": "Politique de confidentialité", + "TermsOfServicePage.schemaDescription": "Terms of Service - rules and conditions under which the service is provided.", "TermsOfServicePage.schemaTitle": "Conditions d'utilisation | {siteTitle}", - "TermsOfServicePage.tosTabTitle": "Conditions d'utilisation", "Topbar.genericError": "Oups, quelque chose s'est mal passé. Veuillez vérifier votre connection et essayer de nouveau.", "Topbar.logoIcon": "Aller à la page d'accueil", "Topbar.menuIcon": "Ouvrir le menu", From 290fb34606c354ae44a1a604820784a6874d80b8 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 15:49:51 +0300 Subject: [PATCH 23/99] AuthenticationPage/SignupPage: use terms-of-service asset --- .../AuthenticationPage.duck.js | 7 +++ .../AuthenticationPage/AuthenticationPage.js | 44 ++++++++++++---- .../AuthenticationPage.module.css | 9 ---- .../AuthenticationPage.test.js.snap | 52 +++++-------------- src/containers/pageDataLoadingAPI.js | 4 ++ src/routeConfiguration.js | 1 + src/translations/de.json | 1 - src/translations/en.json | 1 - src/translations/es.json | 1 - src/translations/fr.json | 1 - src/util/types.js | 10 +++- 11 files changed, 67 insertions(+), 64 deletions(-) create mode 100644 src/containers/AuthenticationPage/AuthenticationPage.duck.js diff --git a/src/containers/AuthenticationPage/AuthenticationPage.duck.js b/src/containers/AuthenticationPage/AuthenticationPage.duck.js new file mode 100644 index 000000000..3de1a6c9d --- /dev/null +++ b/src/containers/AuthenticationPage/AuthenticationPage.duck.js @@ -0,0 +1,7 @@ +import { fetchPageAssets } from '../../ducks/hostedAssets.duck'; +export const ASSET_NAME = 'terms-of-service'; + +export const loadData = (params, search) => dispatch => { + const pageAsset = { termsOfServicePage: `content/pages/${ASSET_NAME}.json` }; + return dispatch(fetchPageAssets(pageAsset, true)); +}; diff --git a/src/containers/AuthenticationPage/AuthenticationPage.js b/src/containers/AuthenticationPage/AuthenticationPage.js index cc8f8a1a7..47b9e02f5 100644 --- a/src/containers/AuthenticationPage/AuthenticationPage.js +++ b/src/containers/AuthenticationPage/AuthenticationPage.js @@ -18,6 +18,12 @@ import { isSignupEmailTakenError, isTooManyEmailVerificationRequestsError, } from '../../util/errors'; + +import { login, authenticationInProgress, signup, signupWithIdp } from '../../ducks/Auth.duck'; +import { isScrollingDisabled } from '../../ducks/UI.duck'; +import { sendVerificationEmail } from '../../ducks/user.duck'; +import { manageDisableScrolling } from '../../ducks/UI.duck'; + import { Page, NamedLink, @@ -33,14 +39,15 @@ import { LayoutWrapperFooter, Footer, Modal, - TermsOfService, } from '../../components'; import { ConfirmSignupForm, LoginForm, SignupForm } from '../../forms'; + import { TopbarContainer } from '../../containers'; -import { login, authenticationInProgress, signup, signupWithIdp } from '../../ducks/Auth.duck'; -import { isScrollingDisabled } from '../../ducks/UI.duck'; -import { sendVerificationEmail } from '../../ducks/user.duck'; -import { manageDisableScrolling } from '../../ducks/UI.duck'; + +import { + TermsOfServiceContent, + TOS_ASSET_NAME, +} from '../../containers/TermsOfServicePage/TermsOfServicePage'; import css from './AuthenticationPage.module.css'; import { FacebookLogo, GoogleLogo } from './socialLoginLogos'; @@ -84,6 +91,9 @@ export class AuthenticationPageComponent extends Component { sendVerificationEmailError, onResendVerificationEmail, onManageDisableScrolling, + tosAssetsData, + tosFetchInProgress, + tosFetchError, } = this.props; const isConfirm = tab === 'confirm'; @@ -416,10 +426,11 @@ export class AuthenticationPageComponent extends Component { onManageDisableScrolling={onManageDisableScrolling} > <div className={css.termsWrapper}> - <h2 className={css.termsHeading}> - <FormattedMessage id="AuthenticationPage.termsHeading" /> - </h2> - <TermsOfService /> + <TermsOfServiceContent + inProgress={tosFetchInProgress} + error={tosFetchError} + data={tosAssetsData?.[camelize(TOS_ASSET_NAME)]?.data} + /> </div> </Modal> </LayoutWrapperMain> @@ -440,6 +451,9 @@ AuthenticationPageComponent.defaultProps = { tab: 'signup', sendVerificationEmailError: null, showSocialLoginsForTests: false, + tosAssetsData: null, + tosFetchInProgress: false, + tosFetchError: null, }; const { bool, func, object, oneOf, shape } = PropTypes; @@ -462,6 +476,12 @@ AuthenticationPageComponent.propTypes = { onResendVerificationEmail: func.isRequired, onManageDisableScrolling: func.isRequired, + // to fetch terms-of-service page asset + // which is shown in modal + tosAssetsData: object, + tosFetchInProgress: bool, + tosFetchError: propTypes.error, + // from withRouter location: shape({ state: object }).isRequired, @@ -472,6 +492,9 @@ AuthenticationPageComponent.propTypes = { const mapStateToProps = state => { const { isAuthenticated, loginError, signupError, confirmError } = state.Auth; const { currentUser, sendVerificationEmailInProgress, sendVerificationEmailError } = state.user; + const { pageAssetsData: tosAssetsData, inProgress: tosFetchInProgress, error: tosFetchError } = + state.hostedAssets || {}; + return { authInProgress: authenticationInProgress(state), currentUser, @@ -482,6 +505,9 @@ const mapStateToProps = state => { confirmError, sendVerificationEmailInProgress, sendVerificationEmailError, + tosAssetsData, + tosFetchInProgress, + tosFetchError, }; }; diff --git a/src/containers/AuthenticationPage/AuthenticationPage.module.css b/src/containers/AuthenticationPage/AuthenticationPage.module.css index ca495c9eb..e7556a6e6 100644 --- a/src/containers/AuthenticationPage/AuthenticationPage.module.css +++ b/src/containers/AuthenticationPage/AuthenticationPage.module.css @@ -100,15 +100,6 @@ } } -.termsHeading { - composes: h1 from global; - margin: 0 0 19px 0; - - @media (--viewportMedium) { - margin: 0 0 19px 0; - } -} - /* ================ Hide Top bar in screens smaller than 768px ================ */ .hideOnMobile { diff --git a/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap b/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap index 4bc9fa721..02f432c38 100644 --- a/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap +++ b/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap @@ -98,16 +98,9 @@ exports[`AuthenticationPageComponent matches snapshot 1`] = ` <div className="termsWrapper" > - <h2 - className="termsHeading" - > - <MemoizedFormattedMessage - id="AuthenticationPage.termsHeading" - /> - </h2> - <TermsOfService - className={null} - rootClassName={null} + <TermsOfServiceContent + error={null} + inProgress={false} /> </div> </injectIntl(ModalComponent)> @@ -261,16 +254,9 @@ exports[`AuthenticationPageComponent with Facebook login matches snapshot 1`] = <div className="termsWrapper" > - <h2 - className="termsHeading" - > - <MemoizedFormattedMessage - id="AuthenticationPage.termsHeading" - /> - </h2> - <TermsOfService - className={null} - rootClassName={null} + <TermsOfServiceContent + error={null} + inProgress={false} /> </div> </injectIntl(ModalComponent)> @@ -469,16 +455,9 @@ exports[`AuthenticationPageComponent with Google and Facebook login matches snap <div className="termsWrapper" > - <h2 - className="termsHeading" - > - <MemoizedFormattedMessage - id="AuthenticationPage.termsHeading" - /> - </h2> - <TermsOfService - className={null} - rootClassName={null} + <TermsOfServiceContent + error={null} + inProgress={false} /> </div> </injectIntl(ModalComponent)> @@ -651,16 +630,9 @@ exports[`AuthenticationPageComponent with Google login matches snapshot 1`] = ` <div className="termsWrapper" > - <h2 - className="termsHeading" - > - <MemoizedFormattedMessage - id="AuthenticationPage.termsHeading" - /> - </h2> - <TermsOfService - className={null} - rootClassName={null} + <TermsOfServiceContent + error={null} + inProgress={false} /> </div> </injectIntl(ModalComponent)> diff --git a/src/containers/pageDataLoadingAPI.js b/src/containers/pageDataLoadingAPI.js index 97c212ee0..139029b9e 100644 --- a/src/containers/pageDataLoadingAPI.js +++ b/src/containers/pageDataLoadingAPI.js @@ -1,6 +1,7 @@ /** * Export loadData calls from ducks modules of different containers */ +import { loadData as AuthenticationPageLoader } from './AuthenticationPage/AuthenticationPage.duck'; import { loadData as LandingPageLoader } from './LandingPage/LandingPage.duck'; import { setInitialValues as CheckoutPageInitialValues } from './CheckoutPage/CheckoutPage.duck'; import { loadData as CMSPageLoader } from './CMSPage/CMSPage.duck'; @@ -22,6 +23,9 @@ import { const getPageDataLoadingAPI = () => { return { + AuthenticationPage: { + loadData: AuthenticationPageLoader, + }, LandingPage: { loadData: LandingPageLoader, }, diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index d5ac3f4f1..481e61d92 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -175,6 +175,7 @@ const routeConfiguration = () => { name: 'SignupPage', component: AuthenticationPage, extraProps: { tab: 'signup' }, + loadData: pageDataLoadingAPI.AuthenticationPage.loadData, }, { path: '/confirm', diff --git a/src/translations/de.json b/src/translations/de.json index 26f1b74ee..d9fec1add 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -38,7 +38,6 @@ "AuthenticationPage.signupLinkText": "Registrieren", "AuthenticationPage.signupWithFacebook": "Sign up with Facebook", "AuthenticationPage.signupWithGoogle": "Sign up with Google", - "AuthenticationPage.termsHeading": "Allgemeine Geschäftsbedingungen", "AuthenticationPage.verifyEmailClose": "SPÄTER", "AuthenticationPage.verifyEmailText": "Danke fürs Registrieren! Ein kleiner Schritt noch. Wir müssen deine Email verifizieren, um dich kontaktieren zu können. Bitte klicke auf den Link, den wir an {email} geschickt haben.", "AuthenticationPage.verifyEmailTitle": "{name}, bitte prüfe deine Email-Inbox um deine Email zu verifizieren", diff --git a/src/translations/en.json b/src/translations/en.json index edf1263bd..0f12b2f64 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -38,7 +38,6 @@ "AuthenticationPage.signupLinkText": "Sign up", "AuthenticationPage.signupWithFacebook": "Sign up with Facebook", "AuthenticationPage.signupWithGoogle": "Sign up with Google", - "AuthenticationPage.termsHeading": "Terms of Service", "AuthenticationPage.verifyEmailClose": "LATER", "AuthenticationPage.verifyEmailText": "Thanks for signing up! There's one quick step left. To be able to contact you, we need you to verify your email address. Please click the link we sent to {email}.", "AuthenticationPage.verifyEmailTitle": "{name}, check your inbox to verify your email", diff --git a/src/translations/es.json b/src/translations/es.json index 12a88780d..1a9b77a2f 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -38,7 +38,6 @@ "AuthenticationPage.signupLinkText": "Regístrate", "AuthenticationPage.signupWithFacebook": "Registrarse con Facebook", "AuthenticationPage.signupWithGoogle": "Regístrate con Google", - "AuthenticationPage.termsHeading": "Términos de servicio", "AuthenticationPage.verifyEmailClose": "MÁS TARDE", "AuthenticationPage.verifyEmailText": "¡Gracias por registrarte! Sólo te queda un paso más. Para poder contactarte, necesitamos que verifiques tu cuenta de correo electrónico. Por favor, haz clic en el link que te hemos enviado a {email}.", "AuthenticationPage.verifyEmailTitle": "{name}, revisa tu buzón para verificar tu correo electrónico", diff --git a/src/translations/fr.json b/src/translations/fr.json index 1bbf57807..44ada93a2 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -38,7 +38,6 @@ "AuthenticationPage.signupLinkText": "S'inscrire", "AuthenticationPage.signupWithFacebook": "S'inscrire avec Facebook", "AuthenticationPage.signupWithGoogle": "S'inscrire avec Google", - "AuthenticationPage.termsHeading": "Conditions d'utilisation", "AuthenticationPage.verifyEmailClose": "PLUS TARD", "AuthenticationPage.verifyEmailText": "Merci de nous avoir rejoint ! Il ne reste plus qu'une petite étape. Pour que nous puissions vous contacter, nous devons vérifier votre adresse email. Veuillez cliquer sur le lien que nous venons d'envoyer à {email}.", "AuthenticationPage.verifyEmailTitle": "{name}, jetez un œil à votre boîte email pour vérifier votre adresse email", diff --git a/src/util/types.js b/src/util/types.js index 12c056c8d..ead9f31d0 100644 --- a/src/util/types.js +++ b/src/util/types.js @@ -488,7 +488,6 @@ const ERROR_CODES = [ ]; // API error -// TODO this is likely to change soonish propTypes.apiError = shape({ id: propTypes.uuid.isRequired, status: number.isRequired, @@ -497,6 +496,13 @@ propTypes.apiError = shape({ meta: object, }); +propTypes.assetDeliveryApiError = shape({ + code: oneOf(ERROR_CODES).isRequired, + id: string.isRequired, + status: number.isRequired, + title: string.isRequired, +}); + // Storable error prop type. (Error object should not be stored as it is.) propTypes.error = shape({ type: propTypes.value('error').isRequired, @@ -504,7 +510,7 @@ propTypes.error = shape({ message: string, status: number, statusText: string, - apiErrors: arrayOf(propTypes.apiError), + apiErrors: arrayOf(oneOfType([propTypes.apiError, propTypes.assetDeliveryApiError])), }); // Options for showing just date or date and time on BookingTimeInfo and BookingBreakdown From b4443ec367a31432f34d05420b3dd27dcfae30bb Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 16:15:24 +0300 Subject: [PATCH 24/99] PrivacyPolicyPage is built with PageBuilder and Asset Delivery API. It defaults to fallback page --- .../PrivacyPolicyPage/FallbackPage.js | 51 ++++++ .../PrivacyPolicyPage.duck.js | 7 + .../PrivacyPolicyPage/PrivacyPolicyPage.js | 155 +++++++++++------- .../PrivacyPolicyPage.module.css | 15 -- src/containers/pageDataLoadingAPI.js | 4 + src/routeConfiguration.js | 1 + src/translations/de.json | 4 +- src/translations/en.json | 4 +- src/translations/es.json | 4 +- src/translations/fr.json | 4 +- 10 files changed, 162 insertions(+), 87 deletions(-) create mode 100644 src/containers/PrivacyPolicyPage/FallbackPage.js create mode 100644 src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js delete mode 100644 src/containers/PrivacyPolicyPage/PrivacyPolicyPage.module.css diff --git a/src/containers/PrivacyPolicyPage/FallbackPage.js b/src/containers/PrivacyPolicyPage/FallbackPage.js new file mode 100644 index 000000000..5b9e5bc22 --- /dev/null +++ b/src/containers/PrivacyPolicyPage/FallbackPage.js @@ -0,0 +1,51 @@ +import React from 'react'; + +import PageBuilder from '../PageBuilder/PageBuilder'; + +// NOTE: You could add the actual Privacy Policy here as a fallback +// instead of showing this error message. +const fallbackPrivacyPolicy = ` +# An error occurred +The web app couldn\'t reach the backend to fetch the Privacy Policy page. + +## Possible actions +Please refresh the page and, if that doesn't help, contact the marketplace administrators. +`; + +// Create fallback content (array of sections) in page asset format: +export const fallbackSections = { + sections: [ + { + sectionType: 'article', + sectionId: 'privacy', + background: { type: 'hexColor', color: '#ffffff' }, + title: { type: 'heading1', content: 'Privacy Policy' }, + blocks: [ + { + blockType: 'default-block', + blockId: 'hero-content', + text: { + type: 'markdown', + content: fallbackPrivacyPolicy, + }, + }, + ], + }, + ], +}; + +// This is the fallback page, in case there's no Privacy Policy asset defined in Console. +const FallbackPage = props => { + const { title, description, schema, contentType } = props; + return ( + <PageBuilder + pageAssetsData={fallbackSections} + title={title} + description={description} + schema={schema} + contentType={contentType} + /> + ); +}; + +export default FallbackPage; diff --git a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js new file mode 100644 index 000000000..57ad02ffd --- /dev/null +++ b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js @@ -0,0 +1,7 @@ +import { fetchPageAssets } from '../../ducks/hostedAssets.duck'; +export const ASSET_NAME = 'privacy-policy'; + +export const loadData = (params, search) => dispatch => { + const pageAsset = { privacyPolicyPage: `content/pages/${ASSET_NAME}.json` }; + return dispatch(fetchPageAssets(pageAsset, true)); +}; diff --git a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js index fe13ba024..046e65ff5 100644 --- a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js +++ b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js @@ -1,91 +1,126 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import { bool, object } from 'prop-types'; import { compose } from 'redux'; import { connect } from 'react-redux'; -import { FormattedMessage, injectIntl, intlShape } from '../../util/reactIntl'; -import { isScrollingDisabled } from '../../ducks/UI.duck'; -import { TopbarContainer } from '../../containers'; -import { - Page, - LayoutSideNavigation, - LayoutWrapperMain, - LayoutWrapperSideNav, - LayoutWrapperTopbar, - LayoutWrapperFooter, - PrivacyPolicy, - Footer, -} from '../../components'; +import { withRouter } from 'react-router-dom'; + import config from '../../config'; +import { injectIntl, intlShape } from '../../util/reactIntl'; + +import { H1 } from '../PageBuilder/Primitives/Heading'; +import PageBuilder, { SectionBuilder } from '../../containers/PageBuilder/PageBuilder'; + +import FallbackPage, { fallbackSections } from './FallbackPage'; +import { ASSET_NAME } from './PrivacyPolicyPage.duck'; + +// This "content-only" component can be used in modals etc. +const PrivacyPolicyContent = props => { + const { inProgress, error, data } = props; + + if (inProgress) { + return null; + } -import css from './PrivacyPolicyPage.module.css'; + // We don't want to add h1 heading twice to the HTML (SEO issue). + // Modal's header is mapped as h2 + const hasContent = data => typeof data?.content === 'string'; + const exposeContentAsChildren = data => { + return hasContent(data) ? { children: data.content } : {}; + }; + const CustomHeading1 = props => <H1 as="h2" {...props} />; + + const hasData = error === null && data; + const sectionsData = hasData ? data : fallbackSections; + + return ( + <SectionBuilder + {...sectionsData} + options={{ + fieldComponents: { + heading1: { component: CustomHeading1, pickValidProps: exposeContentAsChildren }, + }, + isInsideContainer: true, + }} + /> + ); +}; +// Presentational component for PrivacyPolicyPage const PrivacyPolicyPageComponent = props => { - const { scrollingDisabled, intl } = props; - - const tabs = [ - { - text: intl.formatMessage({ id: 'PrivacyPolicyPage.privacyTabTitle' }), - selected: true, - linkProps: { - name: 'PrivacyPolicyPage', - }, - }, - { - text: intl.formatMessage({ id: 'PrivacyPolicyPage.tosTabTitle' }), - selected: false, - linkProps: { - name: 'TermsOfServicePage', - }, - }, - ]; + const { intl, pageAssetsData, inProgress } = props; + + // Schema for search engines (helps them to understand what this page is about) + // http://schema.org + // We are using JSON-LD format const siteTitle = config.siteTitle; + // schemaTitle is used for <title> tag in addition to page schema for SEO const schemaTitle = intl.formatMessage({ id: 'PrivacyPolicyPage.schemaTitle' }, { siteTitle }); - const schema = { + // schemaDescription is used for different <meta> tags in addition to page schema for SEO + const schemaDescription = intl.formatMessage({ id: 'PrivacyPolicyPage.schemaDescription' }); + const openGraphContentType = 'website'; + + // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas + // Read more about schema + // - https://schema.org/ + // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data + const pageSchemaForSEO = { '@context': 'http://schema.org', '@type': 'WebPage', + description: schemaDescription, name: schemaTitle, }; + + // Convert kebab-case to camelCase: my-page-asset > myPageAsset + const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); + return ( - <Page title={schemaTitle} scrollingDisabled={scrollingDisabled} schema={schema}> - <LayoutSideNavigation> - <LayoutWrapperTopbar> - <TopbarContainer currentPage="PrivacyPolicyPage" /> - </LayoutWrapperTopbar> - <LayoutWrapperSideNav tabs={tabs} /> - <LayoutWrapperMain> - <div className={css.content}> - <h1 className={css.heading}> - <FormattedMessage id="PrivacyPolicyPage.heading" /> - </h1> - <PrivacyPolicy /> - </div> - </LayoutWrapperMain> - <LayoutWrapperFooter> - <Footer /> - </LayoutWrapperFooter> - </LayoutSideNavigation> - </Page> + <PageBuilder + pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} + title={schemaTitle} + description={schemaDescription} + schema={pageSchemaForSEO} + contentType={openGraphContentType} + inProgress={inProgress} + fallbackPage={ + <FallbackPage + title={schemaTitle} + description={schemaDescription} + schema={pageSchemaForSEO} + contentType={openGraphContentType} + /> + } + /> ); }; -const { bool } = PropTypes; - PrivacyPolicyPageComponent.propTypes = { - scrollingDisabled: bool.isRequired, - // from injectIntl intl: intlShape.isRequired, + pageAssetsData: object, + inProgress: bool, }; const mapStateToProps = state => { - return { - scrollingDisabled: isScrollingDisabled(state), - }; + const { pageAssetsData, inProgress } = state.hostedAssets || {}; + return { pageAssetsData, inProgress }; }; +// Note: it is important that the withRouter HOC is **outside** the +// connect HOC, otherwise React Router won't rerender any Route +// components since connect implements a shouldComponentUpdate +// lifecycle hook. +// +// See: https://github.com/ReactTraining/react-router/issues/4671 const PrivacyPolicyPage = compose( + withRouter, connect(mapStateToProps), injectIntl )(PrivacyPolicyPageComponent); +export { + ASSET_NAME as PRIVACY_POLICY_ASSET_NAME, + PrivacyPolicyPageComponent, + PrivacyPolicyContent, +}; + export default PrivacyPolicyPage; diff --git a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.module.css b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.module.css deleted file mode 100644 index 7e143c376..000000000 --- a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.module.css +++ /dev/null @@ -1,15 +0,0 @@ -@import '../../styles/customMediaQueries.css'; - -.heading { - margin: 5px 0 18px 0; - - @media (--viewportMedium) { - margin: 8px 0 24px 0; - } -} - -.content { - @media (--viewportLarge) { - max-width: 563px; - } -} diff --git a/src/containers/pageDataLoadingAPI.js b/src/containers/pageDataLoadingAPI.js index 139029b9e..fff8bfac3 100644 --- a/src/containers/pageDataLoadingAPI.js +++ b/src/containers/pageDataLoadingAPI.js @@ -12,6 +12,7 @@ import { loadData as InboxPageLoader } from './InboxPage/InboxPage.duck'; import { loadData as ListingPageLoader } from './ListingPage/ListingPage.duck'; import { loadData as ManageListingsPageLoader } from './ManageListingsPage/ManageListingsPage.duck'; import { loadData as PaymentMethodsPageLoader } from './PaymentMethodsPage/PaymentMethodsPage.duck'; +import { loadData as PrivacyPolicyPageLoader } from './PrivacyPolicyPage/PrivacyPolicyPage.duck'; import { loadData as ProfilePageLoader } from './ProfilePage/ProfilePage.duck'; import { loadData as SearchPageLoader } from './SearchPage/SearchPage.duck'; import { loadData as StripePayoutPageLoader } from './StripePayoutPage/StripePayoutPage.duck'; @@ -56,6 +57,9 @@ const getPageDataLoadingAPI = () => { PaymentMethodsPage: { loadData: PaymentMethodsPageLoader, }, + PrivacyPolicyPage: { + loadData: PrivacyPolicyPageLoader, + }, ProfilePage: { loadData: ProfilePageLoader, }, diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index 481e61d92..eee9e56a6 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -302,6 +302,7 @@ const routeConfiguration = () => { path: '/privacy-policy', name: 'PrivacyPolicyPage', component: PrivacyPolicyPage, + loadData: pageDataLoadingAPI.PrivacyPolicyPage.loadData, }, { path: '/styleguide', diff --git a/src/translations/de.json b/src/translations/de.json index d9fec1add..e65c1976e 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -647,10 +647,8 @@ "PriceFilterForm.clear": "Löschen", "PriceFilterForm.label": "Preisspanne:", "PriceFilterForm.submit": "Anwenden", - "PrivacyPolicyPage.heading": "Saunatime Datenschutzbestimmungen", - "PrivacyPolicyPage.privacyTabTitle": "Datenschutz", + "PrivacyPolicyPage.schemaDescription": "The privacy policy of the service.", "PrivacyPolicyPage.schemaTitle": "Datenschutzbestimmungen | {siteTitle}", - "PrivacyPolicyPage.tosTabTitle": "AGB", "ProfilePage.desktopHeading": "Hallo, ich bin {name}.", "ProfilePage.editProfileLinkDesktop": "Profil bearbeiten", "ProfilePage.editProfileLinkMobile": "Bearbeiten", diff --git a/src/translations/en.json b/src/translations/en.json index 0f12b2f64..d0b298450 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -647,10 +647,8 @@ "PriceFilterForm.clear": "Clear", "PriceFilterForm.label": "Price range:", "PriceFilterForm.submit": "Apply", - "PrivacyPolicyPage.heading": "Saunatime Privacy Policy", - "PrivacyPolicyPage.privacyTabTitle": "Privacy Policy", + "PrivacyPolicyPage.schemaDescription": "The privacy policy of the service.", "PrivacyPolicyPage.schemaTitle": "Privacy Policy | {siteTitle}", - "PrivacyPolicyPage.tosTabTitle": "Terms of Service", "ProfilePage.desktopHeading": "Hello, I'm {name}.", "ProfilePage.editProfileLinkDesktop": "Edit profile", "ProfilePage.editProfileLinkMobile": "Edit", diff --git a/src/translations/es.json b/src/translations/es.json index 1a9b77a2f..a506c3942 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -647,10 +647,8 @@ "PriceFilterForm.clear": "Borrar", "PriceFilterForm.label": "Rango de precio:", "PriceFilterForm.submit": "Aplicar", - "PrivacyPolicyPage.heading": "Política de privacidad de Saunatime", - "PrivacyPolicyPage.privacyTabTitle": "Política de privacidad", + "PrivacyPolicyPage.schemaDescription": "The privacy policy of the service.", "PrivacyPolicyPage.schemaTitle": "Política de privacidad | {siteTitle}", - "PrivacyPolicyPage.tosTabTitle": "Términos y condiciones", "ProfilePage.desktopHeading": "Hola, soy {name}.", "ProfilePage.editProfileLinkDesktop": "Editar perfil", "ProfilePage.editProfileLinkMobile": "Editar", diff --git a/src/translations/fr.json b/src/translations/fr.json index 44ada93a2..1f9c68912 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -647,10 +647,8 @@ "PriceFilterForm.clear": "Effacer", "PriceFilterForm.label": "Gamme de prix :", "PriceFilterForm.submit": "Valider", - "PrivacyPolicyPage.heading": "Politique de confidentialité de Saunatime", - "PrivacyPolicyPage.privacyTabTitle": "Politique de confidentialité", + "PrivacyPolicyPage.schemaDescription": "The privacy policy of the service.", "PrivacyPolicyPage.schemaTitle": "Politique de confidentialité | {siteTitle}", - "PrivacyPolicyPage.tosTabTitle": "Conditions d'utilisation", "ProfilePage.desktopHeading": "Bonjour, je suis {name}.", "ProfilePage.editProfileLinkDesktop": "Modifier le profil", "ProfilePage.editProfileLinkMobile": "Modifier", From 8dba94f10dcd4c1fb0843e59fad8ef28fa1c406c Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 17:09:02 +0300 Subject: [PATCH 25/99] Add util/string.js (to provide camelize helper) --- src/containers/AuthenticationPage/AuthenticationPage.js | 1 + src/containers/LandingPage/LandingPage.js | 4 +--- src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js | 4 +--- src/containers/TermsOfServicePage/TermsOfServicePage.js | 4 +--- src/util/string.js | 2 ++ 5 files changed, 6 insertions(+), 9 deletions(-) create mode 100644 src/util/string.js diff --git a/src/containers/AuthenticationPage/AuthenticationPage.js b/src/containers/AuthenticationPage/AuthenticationPage.js index 47b9e02f5..03556396f 100644 --- a/src/containers/AuthenticationPage/AuthenticationPage.js +++ b/src/containers/AuthenticationPage/AuthenticationPage.js @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { isEmpty } from 'lodash'; import routeConfiguration from '../../routeConfiguration'; +import { camelize } from '../../util/string'; import { pathByRouteName } from '../../util/routes'; import { apiBaseUrl } from '../../util/api'; import { FormattedMessage, injectIntl, intlShape } from '../../util/reactIntl'; diff --git a/src/containers/LandingPage/LandingPage.js b/src/containers/LandingPage/LandingPage.js index dd2fd9f76..e6fd373f3 100644 --- a/src/containers/LandingPage/LandingPage.js +++ b/src/containers/LandingPage/LandingPage.js @@ -6,6 +6,7 @@ import { withRouter } from 'react-router-dom'; import config from '../../config'; import { injectIntl, intlShape } from '../../util/reactIntl'; +import { camelize } from '../../util/string'; import PageBuilder from '../../containers/PageBuilder/PageBuilder'; @@ -41,9 +42,6 @@ export const LandingPageComponent = props => { image: [schemaImage], }; - // Convert kebab-case to camelCase: my-page-asset > myPageAsset - const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); - return ( <PageBuilder pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} diff --git a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js index 046e65ff5..769c43c91 100644 --- a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js +++ b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js @@ -6,6 +6,7 @@ import { withRouter } from 'react-router-dom'; import config from '../../config'; import { injectIntl, intlShape } from '../../util/reactIntl'; +import { camelize } from '../../util/string'; import { H1 } from '../PageBuilder/Primitives/Heading'; import PageBuilder, { SectionBuilder } from '../../containers/PageBuilder/PageBuilder'; @@ -70,9 +71,6 @@ const PrivacyPolicyPageComponent = props => { name: schemaTitle, }; - // Convert kebab-case to camelCase: my-page-asset > myPageAsset - const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); - return ( <PageBuilder pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} diff --git a/src/containers/TermsOfServicePage/TermsOfServicePage.js b/src/containers/TermsOfServicePage/TermsOfServicePage.js index b0f1b8040..5d2a88218 100644 --- a/src/containers/TermsOfServicePage/TermsOfServicePage.js +++ b/src/containers/TermsOfServicePage/TermsOfServicePage.js @@ -6,6 +6,7 @@ import { withRouter } from 'react-router-dom'; import config from '../../config'; import { injectIntl, intlShape } from '../../util/reactIntl'; +import { camelize } from '../../util/string'; import { H1 } from '../PageBuilder/Primitives/Heading'; import PageBuilder, { SectionBuilder } from '../../containers/PageBuilder/PageBuilder'; @@ -70,9 +71,6 @@ const TermsOfServicePageComponent = props => { name: schemaTitle, }; - // Convert kebab-case to camelCase: my-page-asset > myPageAsset - const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); - return ( <PageBuilder pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} diff --git a/src/util/string.js b/src/util/string.js new file mode 100644 index 000000000..35af6a012 --- /dev/null +++ b/src/util/string.js @@ -0,0 +1,2 @@ +// Convert kebab-case to camelCase: my-page-asset > myPageAsset +export const camelize = s => s.replace(/-(.)/g, l => l[1].toUpperCase()); From ee14555d4b6d27b503b8bd0c4ced7f4692883e01 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 17:10:43 +0300 Subject: [PATCH 26/99] Remove hard-coded AboutPage in favor of CMSPage and about page asset --- src/components/Footer/Footer.js | 9 +- src/containers/AboutPage/AboutPage.js | 98 ------------------ src/containers/AboutPage/AboutPage.module.css | 56 ---------- src/containers/AboutPage/about-us-1056.jpg | Bin 96014 -> 0 bytes src/routeConfiguration.js | 6 -- 5 files changed, 7 insertions(+), 162 deletions(-) delete mode 100644 src/containers/AboutPage/AboutPage.js delete mode 100644 src/containers/AboutPage/AboutPage.module.css delete mode 100644 src/containers/AboutPage/about-us-1056.jpg diff --git a/src/components/Footer/Footer.js b/src/components/Footer/Footer.js index 8ddf1e9b3..ee4f0d406 100644 --- a/src/components/Footer/Footer.js +++ b/src/components/Footer/Footer.js @@ -87,7 +87,7 @@ const Footer = props => { </NamedLink> </li> <li className={css.listItem}> - <NamedLink name="AboutPage" className={css.link}> + <NamedLink name="CMSPage" params={{ pageId: 'about' }} className={css.link}> <FormattedMessage id="Footer.toAboutPage" /> </NamedLink> </li> @@ -102,7 +102,12 @@ const Footer = props => { </NamedLink> </li> <li className={css.listItem}> - <NamedLink name="AboutPage" to={{ hash: '#contact' }} className={css.link}> + <NamedLink + name="CMSPage" + params={{ pageId: 'about' }} + to={{ hash: '#contact' }} + className={css.link} + > <FormattedMessage id="Footer.toContactPage" /> </NamedLink> </li> diff --git a/src/containers/AboutPage/AboutPage.js b/src/containers/AboutPage/AboutPage.js deleted file mode 100644 index 6516bf2f6..000000000 --- a/src/containers/AboutPage/AboutPage.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import config from '../../config'; -import { twitterPageURL } from '../../util/urlHelpers'; -import { StaticPage, TopbarContainer } from '../../containers'; -import { - LayoutSingleColumn, - LayoutWrapperTopbar, - LayoutWrapperMain, - LayoutWrapperFooter, - Footer, - ExternalLink, -} from '../../components'; - -import css from './AboutPage.module.css'; -import image from './about-us-1056.jpg'; - -const AboutPage = () => { - const { siteTwitterHandle, siteFacebookPage } = config; - const siteTwitterPage = twitterPageURL(siteTwitterHandle); - - // prettier-ignore - return ( - <StaticPage - title="About Us" - schema={{ - '@context': 'http://schema.org', - '@type': 'AboutPage', - description: 'About Saunatime', - name: 'About page', - }} - > - <LayoutSingleColumn> - <LayoutWrapperTopbar> - <TopbarContainer /> - </LayoutWrapperTopbar> - - <LayoutWrapperMain className={css.staticPageWrapper}> - <h1 className={css.pageTitle}>Experience the unique Finnish home sauna.</h1> - <img className={css.coverImage} src={image} alt="My first ice cream." /> - - <div className={css.contentWrapper}> - <div className={css.contentSide}> - <p>Did you know that Finland has 3.2 million saunas - almost one sauna per person!</p> - </div> - - <div className={css.contentMain}> - <h2> - Most of the Finnish saunas are located at the homes of individuals - indeed, most - people in Finland live in an apartment with sauna in it. In addition, lots of people - have lakeside summer cottages, which also typically come with a separate sauna - building near the waterfront. - </h2> - - <p> - To truly experience a Finnish sauna, you need to look beyond the public saunas, and - instead visit a real home or cottage sauna. Saunatime makes this possible for - everyone. All our saunas are owned by individuals willing to let tourists and other - curious visitors to enter their sacred spaces. - </p> - - <h3 className={css.subtitle}>Are you a sauna owner?</h3> - - <p> - Saunatime offers you a good way to earn some extra cash! If you're not using your - sauna every evening, why not rent it to other people while it's free. And even if - you are using your sauna every evening (we understand, it's so good), why not invite - other people to join you when the sauna is already warm! A shared sauna experience - is often a more fulfilling one. - </p> - - <h3 id="contact" className={css.subtitle}> - Create your own marketplace like Saunatime - </h3> - <p> - Saunatime is brought to you by the good folks at{' '} - <ExternalLink href="http://sharetribe.com">Sharetribe</ExternalLink>. Would you like - to create your own marketplace platform a bit like Saunatime? Or perhaps a mobile - app? With Sharetribe it's really easy. If you have a marketplace idea in mind, do - get in touch! - </p> - <p> - You can also checkout our{' '} - <ExternalLink href={siteFacebookPage}>Facebook</ExternalLink> and{' '} - <ExternalLink href={siteTwitterPage}>Twitter</ExternalLink>. - </p> - </div> - </div> - </LayoutWrapperMain> - - <LayoutWrapperFooter> - <Footer /> - </LayoutWrapperFooter> - </LayoutSingleColumn> - </StaticPage> - ); -}; - -export default AboutPage; diff --git a/src/containers/AboutPage/AboutPage.module.css b/src/containers/AboutPage/AboutPage.module.css deleted file mode 100644 index e81da962e..000000000 --- a/src/containers/AboutPage/AboutPage.module.css +++ /dev/null @@ -1,56 +0,0 @@ -@import '../../styles/customMediaQueries.css'; - -.pageTitle { - text-align: center; -} - -.staticPageWrapper { - width: calc(100% - 48px); - max-width: 1056px; - margin: 24px auto; - - @media (--viewportMedium) { - width: calc(100% - 72px); - margin: 72px auto; - } -} - -.coverImage { - width: 100%; - height: 528px; - border-radius: 4px; - object-fit: cover; - margin: 32px 0 40px; -} - -.contentWrapper { - display: flex; - flex-wrap: wrap; - - @media (--viewportMedium) { - flex-wrap: nowrap; - } -} - -.contentSide { - font-style: italic; - - @media (--viewportMedium) { - width: 193px; - margin-right: 103px; - margin-top: 8px; - } -} - -.contentMain { - width: 90%; - - @media (--viewportMedium) { - max-width: 650px; - } -} - -.subtitle { - margin-top: 32px; - margin-bottom: 16px; -} diff --git a/src/containers/AboutPage/about-us-1056.jpg b/src/containers/AboutPage/about-us-1056.jpg deleted file mode 100644 index 3f21607346b3f4b0cbd1f3914945f4bbdc5c75ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 96014 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 &`-VN-~Ne>b#GN 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 Ts_Ii_*lG#qH2@5Qj<x^UAOddf diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index eee9e56a6..c46060066 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -10,7 +10,6 @@ import { NamedRedirect } from './components'; const pageDataLoadingAPI = getPageDataLoadingAPI(); -const AboutPage = loadable(() => import(/* webpackChunkName: "AboutPage" */ './containers/AboutPage/AboutPage')); const AuthenticationPage = loadable(() => import(/* webpackChunkName: "AuthenticationPage" */ './containers/AuthenticationPage/AuthenticationPage')); const CheckoutPage = loadable(() => import(/* webpackChunkName: "CheckoutPage" */ './containers/CheckoutPage/CheckoutPage')); const CMSPage = loadable(() => import(/* webpackChunkName: "CMSPage" */ './containers/CMSPage/CMSPage')); @@ -71,11 +70,6 @@ const routeConfiguration = () => { component: CMSPage, loadData: pageDataLoadingAPI.CMSPage.loadData, }, - { - path: '/about', - name: 'AboutPage', - component: AboutPage, - }, { path: '/s', name: 'SearchPage', From 43eaf202aad6a51edb1f10a6f07f49a378b94081 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 5 Sep 2022 15:26:13 +0300 Subject: [PATCH 27/99] Add PreviewResolverPage for Console preview links. E.g. '/preview?asset-path=content/pages/about.json' --- .../PreviewResolverPage.js | 51 +++++++++++++++++++ src/routeConfiguration.js | 9 ++++ 2 files changed, 60 insertions(+) create mode 100644 src/containers/PreviewResolverPage/PreviewResolverPage.js diff --git a/src/containers/PreviewResolverPage/PreviewResolverPage.js b/src/containers/PreviewResolverPage/PreviewResolverPage.js new file mode 100644 index 000000000..3f8879b77 --- /dev/null +++ b/src/containers/PreviewResolverPage/PreviewResolverPage.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { shape, string } from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { parse } from '../../util/urlHelpers'; +import { NamedRedirect } from '../../components'; + +// Get page asset name from asset path +const getPageAssetName = assetPath => { + const cmsPageRegex = new RegExp('content/pages/(.*).json'); + const matches = assetPath.match(cmsPageRegex); + // The asset name is found from the matches array; + return matches?.[1]; +}; + +// This page resolves what route on the client app should be shown, +// when Console redirects the operator to the client app. +// The URL that Flex Console uses looks like this: +// https://my.marketplace.com/preview?asset-path=content/pages/privacy-policy.json +// +// If the asset path starts with "content/pages", +// we try to pick the asset name (e.g. privacy-policy) and resolve the correct route based on that. +const PreviewResolverPage = props => { + const search = props?.location?.search; + const parsedQueryString = parse(search); + const assetPath = parsedQueryString?.['asset-path'] || ''; + const pageAssetName = getPageAssetName(assetPath); + + const toTermsOfServicePage = <NamedRedirect name="TermsOfServicePage" />; + const toPrivacyPolicyPage = <NamedRedirect name="PrivacyPolicyPage" />; + const toCMSPage = <NamedRedirect name="CMSPage" params={{ pageId: pageAssetName }} />; + const toLandingPage = <NamedRedirect name="LandingPage" />; + + // Check if a specific page should be shown + // If pageAssetName can't be detected, redirect to LandingPage + return pageAssetName === 'terms-of-service' + ? toTermsOfServicePage + : pageAssetName === 'privacy-policy' + ? toPrivacyPolicyPage + : pageAssetName && pageAssetName !== 'landing-page' + ? toCMSPage + : toLandingPage; +}; + +PreviewResolverPage.propTypes = { + // from withRouter + location: shape({ + search: string.isRequired, + }).isRequired, +}; + +export default withRouter(PreviewResolverPage); diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index c46060066..8e9808119 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -2,6 +2,7 @@ import React from 'react'; import loadable from '@loadable/component'; import getPageDataLoadingAPI from './containers/pageDataLoadingAPI'; import { NotFoundPage } from './containers'; +import PreviewResolverPage from './containers/PreviewResolverPage/PreviewResolverPage'; // routeConfiguration needs to initialize containers first // Otherwise, components will import form container eventually and @@ -350,6 +351,14 @@ const routeConfiguration = () => { component: EmailVerificationPage, loadData: pageDataLoadingAPI.EmailVerificationPage.loadData, }, + // Do not change this path! + // + // The API expects that the application implements /preview endpoint + { + path: '/preview', + name: 'PreviewResolverPage', + component: PreviewResolverPage , + }, ]; }; From 601b5a5e96d18b89da80f8be24b839995febfd32 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 22 Sep 2022 16:01:26 +0300 Subject: [PATCH 28/99] Fix: don't include empty header container --- src/containers/PageBuilder/Field/Field.helpers.js | 5 +++-- src/containers/PageBuilder/Field/Field.js | 11 +++++++++++ src/containers/PageBuilder/Field/index.js | 2 +- .../SectionArticle/SectionArticle.js | 15 +++++++++------ .../SectionCarousel/SectionCarousel.js | 15 +++++++++------ .../SectionColumns/SectionColumns.js | 15 +++++++++------ .../SectionFeatures/SectionFeatures.js | 15 +++++++++------ 7 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index 370c1306d..84cf7e263 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -4,7 +4,7 @@ import { sanitizeUrl } from '../../../util/sanitize'; // Pickers for valid props // ///////////////////////////// -const hasContent = data => typeof data?.content === 'string'; +const hasContent = data => typeof data?.content === 'string' && data?.content.length > 0; /** * Exposes "content" prop as children property, if "content" has type of string. @@ -33,7 +33,8 @@ export const exposeContentString = data => (hasContent(data) ? { content: data.c */ export const exposeLinkProps = data => { const { label, href } = data; - const hasCorrectProps = typeof label === 'string' && typeof href === 'string'; + const hasCorrectProps = + typeof label === 'string' && typeof href === 'string' && label.length > 0 && href.length > 0; // Sanitize the URL. See: src/utl/sanitize.js for more information. const cleanUrl = hasCorrectProps ? sanitizeUrl(href) : null; return cleanUrl ? { children: label, href: cleanUrl } : {}; diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index 77e3afa7c..b17138b93 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -114,6 +114,17 @@ export const validProps = (data, options) => { return null; }; +// Check that the array of given field data is containing some content +// (fieldOptions parameter is needed if custom fields are used) +export const hasDataInFields = (fields, fieldOptions) => { + const hasData = fields.reduce((hasFoundValues, fieldData) => { + const validPropsFromData = validProps(fieldData, fieldOptions); + const hasDataInCurrent = validPropsFromData && Object.keys(validPropsFromData).length > 0; + return hasFoundValues || hasDataInCurrent; + }, false); + return hasData; +}; + //////////////////// // Field selector // //////////////////// diff --git a/src/containers/PageBuilder/Field/index.js b/src/containers/PageBuilder/Field/index.js index 983df2cf3..b6987ee04 100644 --- a/src/containers/PageBuilder/Field/index.js +++ b/src/containers/PageBuilder/Field/index.js @@ -1 +1 @@ -export { default, validProps } from './Field'; +export { default, validProps, hasDataInFields } from './Field'; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index 1dc2c6c55..ead94d95d 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -2,7 +2,7 @@ import React from 'react'; import { arrayOf, bool, func, node, object, oneOf, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field, { validProps } from '../../Field'; +import Field, { validProps, hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; @@ -35,6 +35,7 @@ const SectionArticle = props => { const colorProp = validProps(background, fieldOptions); const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; return ( @@ -46,11 +47,13 @@ const SectionArticle = props => { backgroundImage={backgroundImage} options={fieldOptions} > - <header className={defaultClasses.sectionDetails}> - <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> - <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> - </header> + {hasHeaderFields ? ( + <header className={defaultClasses.sectionDetails}> + <Field data={title} className={defaultClasses.title} options={fieldOptions} /> + <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> + </header> + ) : null} {hasBlocks ? ( <div className={classNames(css.articleMain, { [css.noSidePaddings]: isInsideContainer })}> <BlockBuilder blocks={blocks} options={options} /> diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index d3c4970bc..49822d2ff 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -1,7 +1,7 @@ import React from 'react'; import { arrayOf, func, node, number, object, oneOf, shape, string } from 'prop-types'; -import Field, { validProps } from '../../Field'; +import Field, { validProps, hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; @@ -51,6 +51,7 @@ const SectionCarousel = props => { const colorProp = validProps(background, fieldOptions); const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; return ( @@ -62,11 +63,13 @@ const SectionCarousel = props => { backgroundImage={backgroundImage} options={fieldOptions} > - <header className={defaultClasses.sectionDetails}> - <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> - <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> - </header> + {hasHeaderFields ? ( + <header className={defaultClasses.sectionDetails}> + <Field data={title} className={defaultClasses.title} options={fieldOptions} /> + <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> + </header> + ) : null} {hasBlocks ? ( <div className={getColumnCSS(numColumns)}> <BlockBuilder diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js index 515babdf7..f60093e53 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js @@ -2,7 +2,7 @@ import React from 'react'; import { arrayOf, bool, func, node, number, object, oneOf, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field, { validProps } from '../../Field'; +import Field, { validProps, hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; @@ -52,6 +52,7 @@ const SectionColumns = props => { const colorProp = validProps(background, fieldOptions); const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; return ( @@ -63,11 +64,13 @@ const SectionColumns = props => { backgroundImage={backgroundImage} options={fieldOptions} > - <header className={defaultClasses.sectionDetails}> - <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> - <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> - </header> + {hasHeaderFields ? ( + <header className={defaultClasses.sectionDetails}> + <Field data={title} className={defaultClasses.title} options={fieldOptions} /> + <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> + </header> + ) : null} {hasBlocks ? ( <div className={classNames(getColumnCSS(numColumns), { diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js index 8ab404f7f..485874d33 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -2,7 +2,7 @@ import React from 'react'; import { arrayOf, bool, func, node, object, oneOf, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field, { validProps } from '../../Field'; +import Field, { validProps, hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; @@ -39,6 +39,7 @@ const SectionFeatures = props => { const colorProp = validProps(background, fieldOptions); const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; return ( @@ -50,11 +51,13 @@ const SectionFeatures = props => { backgroundImage={backgroundImage} options={fieldOptions} > - <header className={defaultClasses.sectionDetails}> - <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> - <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> - </header> + {hasHeaderFields ? ( + <header className={defaultClasses.sectionDetails}> + <Field data={title} className={defaultClasses.title} options={fieldOptions} /> + <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> + </header> + ) : null} {hasBlocks ? ( <div className={classNames(css.featuresMain, { [css.noSidePaddings]: isInsideContainer })}> <BlockBuilder From bd1a7cb0d4988e376d2ca9b7ae9cdc00c6d8a70e Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 17:31:56 +0300 Subject: [PATCH 29/99] Rename default-block as defaultBlock --- src/containers/LandingPage/FallbackPage.js | 2 +- .../PageBuilder/BlockBuilder/BlockBuilder.js | 4 +- .../PageBuilder/BlockBuilder/README.md | 8 +- .../PageBuilder/Markdown.example.js | 90 +++++++++---------- .../PageBuilder/PageBuilder.example.js | 24 ++--- .../PageBuilder/SectionBuilder/README.md | 2 +- .../SectionBuilder/SectionBuilder.example.js | 68 +++++++------- .../PrivacyPolicyPage/FallbackPage.js | 2 +- .../TermsOfServicePage/FallbackPage.js | 2 +- 9 files changed, 101 insertions(+), 101 deletions(-) diff --git a/src/containers/LandingPage/FallbackPage.js b/src/containers/LandingPage/FallbackPage.js index 965fb47bb..a9f543aa0 100644 --- a/src/containers/LandingPage/FallbackPage.js +++ b/src/containers/LandingPage/FallbackPage.js @@ -33,7 +33,7 @@ export const fallbackSections = { // }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'hero-content', media: { type: 'image', diff --git a/src/containers/PageBuilder/BlockBuilder/BlockBuilder.js b/src/containers/PageBuilder/BlockBuilder/BlockBuilder.js index c9ccf414c..c9d726445 100644 --- a/src/containers/PageBuilder/BlockBuilder/BlockBuilder.js +++ b/src/containers/PageBuilder/BlockBuilder/BlockBuilder.js @@ -9,7 +9,7 @@ import BlockDefault from './BlockDefault'; /////////////////////////////////////////// const defaultBlockComponents = { - ['default-block']: { component: BlockDefault }, + defaultBlock: { component: BlockDefault }, }; //////////////////// @@ -53,7 +53,7 @@ const BlockBuilder = props => { const propTypeBlock = shape({ blockId: string.isRequired, - blockType: oneOf(['default-block']).isRequired, + blockType: oneOf(['defaultBlock']).isRequired, // Plus all kind of unknown fields. // BlockBuilder doesn't really need to care about those }); diff --git a/src/containers/PageBuilder/BlockBuilder/README.md b/src/containers/PageBuilder/BlockBuilder/README.md index 5d7e9a6d8..0907c6220 100644 --- a/src/containers/PageBuilder/BlockBuilder/README.md +++ b/src/containers/PageBuilder/BlockBuilder/README.md @@ -7,7 +7,7 @@ The default schema for page content has 3 levels that can include content fields - **blocks** (section might contain blocks) This is the builder for block types. Although, at the time of writing, there's only one block type -supported: '**default-block**'. The component called **BlockDefault** handles the rendering of that +supported: '**defaultBlock**'. The component called **BlockDefault** handles the rendering of that block type. ```jsx @@ -15,7 +15,7 @@ block type. ctaButtonClass={css.myCallToActionButton} blocks={[ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'block-1', title: { type: 'heading2', @@ -78,7 +78,7 @@ block type. ```js const defaultBlockComponents = { - ['default-block']: { component: BlockDefault }, + defaultBlock: { component: BlockDefault }, }; ``` @@ -86,6 +86,6 @@ block type. ```js const defaultBlockComponents = { - ['default-block']: { component: BlockMyComponent }, + defaultBlock: { component: BlockMyComponent }, }; ``` diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index c860be374..4197eb062 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -35,13 +35,13 @@ const SectionHeadings = { ingress: { type: 'heading2', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-heading-block-1', title: { type: 'heading3', content: 'Heading syntax' }, text: { type: 'markdown', content: `\`\`\`${mdHeading}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-heading-block-2', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: mdHeading }, @@ -72,25 +72,25 @@ const SectionEmphasis = { title: { type: 'heading2', content: 'Emphasizing text' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-emphasis-block-1', title: { type: 'heading3', content: 'Bold' }, text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisBold) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-emphasis-block-2', title: { type: 'heading3', content: 'Bold' }, text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisBold2) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-emphasis-block-3', title: { type: 'heading3', content: 'Italic' }, text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisItalic) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-emphasis-block-4', title: { type: 'heading3', content: 'Italic' }, text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisItalic2) }, @@ -115,13 +115,13 @@ const SectionLinks = { title: { type: 'heading2', content: 'Links' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-link-block-1', title: { type: 'heading3', content: 'Link syntax' }, text: { type: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-link-block-2', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: mdLinks }, @@ -159,19 +159,19 @@ const SectionHorizontalRules = { title: { type: 'heading2', content: 'Horizontal Rules' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-hr-block-1', title: { type: 'heading3', content: 'With 3 underscore' }, text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-hr-block-2', title: { type: 'heading3', content: 'With 3 dash' }, text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules2) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-hr-block-3', title: { type: 'heading3', content: 'With 3 asterisk' }, text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules3) }, @@ -225,49 +225,49 @@ const SectionLists = { title: { type: 'heading2', content: 'Lists' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-1', title: { type: 'heading3', content: 'Unordered lists' }, text: { type: 'markdown', content: `\`\`\`${unorderedList}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-2', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: unorderedList }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-3', title: { type: 'heading3', content: 'Ordered lists' }, text: { type: 'markdown', content: `\`\`\`${orderedList}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-4', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: orderedList }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-5', title: { type: 'heading3', content: 'Keep all numbers as "1."' }, text: { type: 'markdown', content: `\`\`\`${orderedList2}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-6', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: orderedList2 }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-7', title: { type: 'heading3', content: 'Start numbering with offset' }, text: { type: 'markdown', content: `\`\`\`${orderedList3}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-list-block-8', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: orderedList3 }, @@ -310,37 +310,37 @@ const SectionBlockquotes = { title: { type: 'heading2', content: 'Blockquotes' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-blockquote-block-1', title: { type: 'heading3', content: 'Nested blockquotes' }, text: { type: 'markdown', content: `\`\`\`${blockquotesNested}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-blockquote-block-2', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: blockquotesNested }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-blockquote-block-3', title: { type: 'heading3', content: 'Lazy arrow:' }, text: { type: 'markdown', content: `\`\`\`${blockquotesLazyArray}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-blockquote-block-4', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: blockquotesLazyArray }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-blockquote-block-5', title: { type: 'heading3', content: 'Complex blockquotes' }, text: { type: 'markdown', content: `\`\`\`${blockquotesComplex}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-blockquote-block-6', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: blockquotesComplex }, @@ -371,37 +371,37 @@ const SectionImages = { title: { type: 'heading2', content: 'Images' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-image-block-1', title: { type: 'heading3', content: 'With "alt" for screenreaders' }, text: { type: 'markdown', content: `\`\`\`${mdImage1}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-image-block-2', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: mdImage1 }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-image-block-3', title: { type: 'heading3', content: 'With "alt" and "title"' }, text: { type: 'markdown', content: `\`\`\`${mdImage2}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-image-block-4', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: mdImage2 }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-image-block-5', title: { type: 'heading3', content: 'Footnote style' }, text: { type: 'markdown', content: `\`\`\`${mdImageFootnoteStyle}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-image-block-6', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: mdImageFootnoteStyle }, @@ -439,37 +439,37 @@ const SectionCode = { title: { type: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-1', title: { type: 'heading3', content: 'Inline code uses backticks' }, text: { type: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-2', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: inlineCode }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-3', title: { type: 'heading3', content: 'Code block with indentation (4 spaces)' }, text: { type: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-4', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: codeBlockIndentation }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-5', title: { type: 'heading3', content: 'Code block with backtick "fences" (```)' }, text: { type: 'markdown', content: codeBlockFencesSyntax }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-6', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: codeBlockFences }, @@ -518,13 +518,13 @@ const SectionLinksOnDarkMode = { title: { type: 'heading2', content: 'Links on dark theme' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-link-block-1-dark', title: { type: 'heading3', content: 'Link syntax' }, text: { type: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-link-block-2-dark', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: mdLinks }, @@ -541,37 +541,37 @@ const SectionCodeOnDarkMode = { title: { type: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-1', title: { type: 'heading3', content: 'Inline code uses backticks' }, text: { type: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-2', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: inlineCode }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-3', title: { type: 'heading3', content: 'Code block with indentation (4 spaces)' }, text: { type: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-4', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: codeBlockIndentation }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-5', title: { type: 'heading3', content: 'Code block with backtick "fences" (```)' }, text: { type: 'markdown', content: codeBlockFencesSyntax }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-code-block-6', title: { type: 'heading3', content: '...rendered' }, text: { type: 'markdown', content: codeBlockFences }, diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index 1c52494d0..134ab4c6a 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -153,7 +153,7 @@ export const PageWithBuildInSectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns-section-0-block-1', title: { type: 'heading3', content: 'Column 1' }, text: { @@ -175,7 +175,7 @@ export const PageWithBuildInSectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns-section-1-block-1', title: { type: 'heading3', content: 'Column 1' }, text: { @@ -184,7 +184,7 @@ export const PageWithBuildInSectionColumns = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns-section-1-block-2', title: { type: 'heading3', content: 'Column 2' }, text: { @@ -205,13 +205,13 @@ export const PageWithBuildInSectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns-section-2-block-1', title: { type: 'heading3', content: 'Column 1' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns-section-2-block-2', title: { type: 'heading3', content: 'Column 2' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, @@ -235,19 +235,19 @@ export const PageWithBuildInSectionColumns = { textColor: 'light', blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-3-block-1', title: { type: 'heading3', content: 'Column 1' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-3-block-2', title: { type: 'heading3', content: 'Column 2' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-3-block-3', title: { type: 'heading3', content: 'Column 3' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, @@ -265,25 +265,25 @@ export const PageWithBuildInSectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-1', title: { type: 'heading3', content: 'Column 1' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-2', title: { type: 'heading3', content: 'Column 2' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-3', title: { type: 'heading3', content: 'Column 3' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-4', title: { type: 'heading3', content: 'Column 4' }, media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, diff --git a/src/containers/PageBuilder/SectionBuilder/README.md b/src/containers/PageBuilder/SectionBuilder/README.md index 546a08f4f..a7905e9d1 100644 --- a/src/containers/PageBuilder/SectionBuilder/README.md +++ b/src/containers/PageBuilder/SectionBuilder/README.md @@ -21,7 +21,7 @@ SectionBuilder uses internal component **SectionArticle** to render the section }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-article-section-block-1', text: { type: 'markdown', diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index 3053a07ee..ac1b5c2c3 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -50,7 +50,7 @@ export const SectionArticle = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-article-section-block-1', media: { type: 'image', alt: 'Cute dog smiling', image: imagePlaceholder(600, 800) }, title: { @@ -113,7 +113,7 @@ export const SectionFeatures = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-features-block-1', media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { @@ -131,7 +131,7 @@ export const SectionFeatures = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-features-block-2', media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { @@ -149,7 +149,7 @@ export const SectionFeatures = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-features-block-3', media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { @@ -197,7 +197,7 @@ export const SectionCarousel = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-1', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -215,7 +215,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-2', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -233,7 +233,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-3', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -251,7 +251,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-4', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -269,7 +269,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-5', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -287,7 +287,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-6', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -305,7 +305,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-7', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -323,7 +323,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-8', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -341,7 +341,7 @@ export const SectionCarousel = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-carousel-block-9', media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { @@ -429,7 +429,7 @@ export const SectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column1-block-1', title: { type: 'heading3', content: 'Block 1' }, text: { @@ -438,7 +438,7 @@ export const SectionColumns = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column1-block-2', title: { type: 'heading3', content: 'Block 2' }, text: { @@ -459,7 +459,7 @@ export const SectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column2-block-1', title: { type: 'heading3', content: 'Column 1' }, text: { @@ -473,7 +473,7 @@ export const SectionColumns = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column2-block-2', title: { type: 'heading3', content: 'Column 2' }, text: { @@ -501,7 +501,7 @@ export const SectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column2-block-1-dark', title: { type: 'heading3', content: 'Column 1' }, text: { @@ -515,7 +515,7 @@ export const SectionColumns = { }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column2-block-2-dark', title: { type: 'heading3', content: 'Column 2' }, text: { @@ -541,19 +541,19 @@ export const SectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column3-block-1', media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { type: 'heading3', content: 'Image 1' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column3-block-2', media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, title: { type: 'heading3', content: 'Image 2' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column3-block-3', media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, title: { type: 'heading3', content: 'Image 3' }, @@ -571,25 +571,25 @@ export const SectionColumns = { }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-1-variant-1', media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { type: 'heading3', content: 'Image 1' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-2-variant-1', media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, title: { type: 'heading3', content: 'Image 2' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-3-variant-1', media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, title: { type: 'heading3', content: 'Image 3' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-4-variant-1', media: { type: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 400) }, title: { type: 'heading3', content: 'Image 4' }, @@ -604,31 +604,31 @@ export const SectionColumns = { ingress: { type: 'paragraph', content: 'Portrait images (400x500)' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-1-variant-2', media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 500) }, title: { type: 'heading3', content: 'Image 1' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-2-variant-2', media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 500) }, title: { type: 'heading3', content: 'Image 2' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-3-variant-2', media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 500) }, title: { type: 'heading3', content: 'Image 3' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-4-variant-2', media: { type: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 500) }, title: { type: 'heading3', content: 'Image 4' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-5-variant-2', media: { type: 'image', alt: 'Fifth image', image: imagePlaceholder(400, 500) }, title: { type: 'heading3', content: 'Image 5' }, @@ -643,19 +643,19 @@ export const SectionColumns = { ingress: { type: 'paragraph', content: 'Landscape images (400x300)' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-1-variant-3', media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 300) }, title: { type: 'heading3', content: 'Image 1' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-2-variant-3', media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 300) }, title: { type: 'heading3', content: 'Image 2' }, }, { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'cms-column4-block-3-variant-3', media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 300) }, title: { type: 'heading3', content: 'Image 3' }, diff --git a/src/containers/PrivacyPolicyPage/FallbackPage.js b/src/containers/PrivacyPolicyPage/FallbackPage.js index 5b9e5bc22..199ea602b 100644 --- a/src/containers/PrivacyPolicyPage/FallbackPage.js +++ b/src/containers/PrivacyPolicyPage/FallbackPage.js @@ -22,7 +22,7 @@ export const fallbackSections = { title: { type: 'heading1', content: 'Privacy Policy' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'hero-content', text: { type: 'markdown', diff --git a/src/containers/TermsOfServicePage/FallbackPage.js b/src/containers/TermsOfServicePage/FallbackPage.js index 5665c1a22..f4dd3ccc3 100644 --- a/src/containers/TermsOfServicePage/FallbackPage.js +++ b/src/containers/TermsOfServicePage/FallbackPage.js @@ -22,7 +22,7 @@ export const fallbackSections = { title: { type: 'heading1', content: 'Terms of Service' }, blocks: [ { - blockType: 'default-block', + blockType: 'defaultBlock', blockId: 'hero-content', text: { type: 'markdown', From 71d927592aedbb582d3beb666fad2d0ef277a23c Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 17:33:09 +0300 Subject: [PATCH 30/99] Add CustomBackground Primitive component (uses ResponsiveImage though) --- .../CustomBackground/CustomBackground.js | 63 +++++++++++++++++++ .../CustomBackground.module.css | 13 ++++ .../Primitives/CustomBackground/index.js | 1 + 3 files changed, 77 insertions(+) create mode 100644 src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js create mode 100644 src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.module.css create mode 100644 src/containers/PageBuilder/Primitives/CustomBackground/index.js diff --git a/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js b/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js new file mode 100644 index 000000000..b3340bfcb --- /dev/null +++ b/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { number, objectOf, oneOf, shape, string } from 'prop-types'; +import classNames from 'classnames'; + +import { ResponsiveImage } from '../../../../components/index.js'; + +import css from './CustomBackground.module.css'; + +// BackgroundImage doesn't have enforcable aspectratio +export const CustomBackground = React.forwardRef((props, ref) => { + const { className, rootClassName, alt, backgroundImage, sizes } = props; + + const getVariantNames = img => { + const { variants } = img?.attributes || {}; + return variants ? Object.keys(variants) : []; + }; + + const classes = classNames(rootClassName || css.backgroundImageWrapper, className); + return ( + <div className={classes}> + {backgroundImage ? ( + <ResponsiveImage + className={css.backgroundImage} + ref={ref} + alt={alt} + image={backgroundImage} + variants={getVariantNames(backgroundImage)} + sizes={sizes} + /> + ) : null} + </div> + ); +}); + +CustomBackground.displayName = 'CustomBackground'; + +CustomBackground.defaultProps = { + rootClassName: null, + className: null, + alt: 'background image', + sizes: null, + backgroundImage: null, +}; + +CustomBackground.propTypes = { + rootClassName: string, + className: string, + alt: string, + backgroundImage: shape({ + id: string.isRequired, + type: oneOf(['imageAsset']).isRequired, + attributes: shape({ + variants: objectOf( + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ).isRequired, + }).isRequired, + }), + sizes: string, +}; diff --git a/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.module.css b/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.module.css new file mode 100644 index 000000000..85d3f6267 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.module.css @@ -0,0 +1,13 @@ +.backgroundImageWrapper { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.backgroundImage { + object-fit: cover; + width: 100%; + height: 100%; +} diff --git a/src/containers/PageBuilder/Primitives/CustomBackground/index.js b/src/containers/PageBuilder/Primitives/CustomBackground/index.js new file mode 100644 index 000000000..b285a5201 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/CustomBackground/index.js @@ -0,0 +1 @@ +export { CustomBackground } from './CustomBackground'; From 48cb83885511c8d697e8014fa9d2b2c632434ac2 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 17:38:42 +0300 Subject: [PATCH 31/99] Add CustomBackground to Field.js and add prop validator --- .../PageBuilder/Field/Field.helpers.js | 61 +++++++++++++++++++ src/containers/PageBuilder/Field/Field.js | 3 + 2 files changed, 64 insertions(+) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index 84cf7e263..f178ead7a 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -104,3 +104,64 @@ export const exposeColorProps = data => { const isValidColor = typeof color === 'string' && re.test(color); return isValidColor ? { color } : {}; }; + +/** + * Helper that exposes "color" value, if it contains hexadecimal string like "#FF0000" or "#F00". + * + * @param {String} data E.g. "#FFFFFF" + * @returns Object containing valid color prop. + */ + const exposeColorValue = color => { + const re = new RegExp('^#([0-9a-f]{3}){1,2}$', 'i'); + const isValidColor = typeof color === 'string' && re.test(color); + return isValidColor ? color : null; +}; + +/** + * Exposes background props like "backgroundImage", "color" property, + * if backgroundImage contains imageAsset entity and + * color contains hexadecimal string like "#FF0000" or "#F00". + * + * @param {ImageAsset} data. + * @param {Object} data E.g. "{ type: 'hexColor', color: '#FFFFFF' }" + * @param {textColor} data E.g. "{ type: 'hexColor', color: '#FFFFFF' }" + * @returns object containing color prop. + */ +export const exposeCustomBackgroundProps = data => { + const { backgroundImage, color, textColor, alt } = data; + const { id, type, attributes } = backgroundImage || {}; + + if (!!type && type !== 'imageAsset') { + return {}; + } + + const validColor = exposeColorValue(color); + const isValidColor = !!validColor; + const backgroundColorMaybe = isValidColor ? { color: validColor } : {}; + const isValidTextColor = ['light', 'dark'].includes(textColor); + const textColorMaybe = isValidTextColor ? { textColor } : {}; + + const variantEntries = Object.entries(backgroundImage?.attributes?.variants || {}); + const variants = variantEntries.reduce((validVariants, entry) => { + const [key, value] = entry; + const { url, width, height } = value || {}; + + const isValid = typeof width === 'number' && typeof height === 'number'; + return isValid + ? { + ...validVariants, + [key]: { url: sanitizeUrl(url), width, height }, + } + : validVariants; + }, {}); + + const isValidImage = Object.keys(variants).length > 0; + const sanitizedImage = { id, type, attributes: { ...attributes, variants } }; + const backgroundImageMaybe = isValidImage ? { backgroundImage: sanitizedImage, alt } : {}; + + return { + ...backgroundImageMaybe, + ...backgroundColorMaybe, + ...textColorMaybe, + }; +}; diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index b17138b93..b4b8a24ea 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -11,6 +11,7 @@ import { P } from '../Primitives/P'; import { Code, CodeBlock } from '../Primitives/Code'; import { Link } from '../Primitives/Link'; import { MarkdownImage, BackgroundImage, FieldImage } from '../Primitives/Image'; +import { CustomBackground } from '../Primitives/CustomBackground'; import renderMarkdown from '../markdownProcessor'; @@ -18,6 +19,7 @@ import { exposeContentAsChildren, exposeContentString, exposeLinkProps, + exposeCustomBackgroundProps, exposeImageProps, exposeColorProps, } from './Field.helpers'; @@ -46,6 +48,7 @@ const defaultFieldComponents = { internalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, image: { component: FieldImage, pickValidProps: exposeImageProps }, backgroundImage: { component: BackgroundImage, pickValidProps: exposeImageProps }, + customBackground: { component: CustomBackground, pickValidProps: exposeCustomBackgroundProps }, // markdown content field is pretty complex component markdown: { From 92be9ba41679c676365e994328587c0d60de5236 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 17:40:05 +0300 Subject: [PATCH 32/99] SectionContainer: make it use CustomBackground --- .../SectionContainer/SectionContainer.js | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js index a56955580..241fb6952 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -2,39 +2,29 @@ import React from 'react'; import { func, node, object, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field from '../../Field'; +import Field, { validProps } from '../../Field'; import css from './SectionContainer.module.css'; -// Create Image field for background image -// This will be passed to SectionContainer as responsive "background" image -const BackgroundImageField = props => { - const { className, backgroundImage, options } = props; - return backgroundImage ? ( - <div className={css.backgroundImageWrapper}> - <Field - data={{ ...backgroundImage, type: 'backgroundImage' }} - className={className} - options={options} - /> - </div> - ) : null; -}; - // This component can be used to wrap some common styles and features of Section-level components. // E.g: const SectionHero = props => (<SectionContainer><H1>Hello World!</H1></SectionContainer>); const SectionContainer = props => { - const { className, rootClassName, as, children, backgroundImage, options, ...otherProps } = props; + const { className, rootClassName, id, as, children, background, options, ...otherProps } = props; const Tag = as || 'section'; const classes = classNames(rootClassName || css.root, className); + // Find background color if it is included + const colorProp = validProps(background, options); + const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + return ( - <Tag className={classes} {...otherProps}> - <BackgroundImageField - backgroundImage={backgroundImage} - className={css.backgroundImage} + <Tag className={classes} id={id} style={backgroundColorMaybe} {...otherProps}> + <Field + data={{ ...background, alt: `Background image for ${id}` }} + className={className} options={options} /> + <div className={css.sectionContent}>{children}</div> </Tag> ); @@ -49,7 +39,7 @@ SectionContainer.defaultProps = { className: null, as: 'div', children: null, - backgroundImage: null, + background: null, }; SectionContainer.propTypes = { @@ -57,7 +47,7 @@ SectionContainer.propTypes = { className: string, as: string, children: node, - backgroundImage: object, + background: object, options: propTypeOption, }; From 503ec92d202dc9192c1052ab072d7d52a84be9a7 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 17:41:22 +0300 Subject: [PATCH 33/99] Change sections to work with updated SectionContainer --- .../SectionArticle/SectionArticle.js | 21 +++------------- .../SectionCarousel/SectionCarousel.js | 21 +++------------- .../SectionColumns/SectionColumns.js | 21 +++------------- .../SectionFeatures/SectionFeatures.js | 25 +++++-------------- 4 files changed, 18 insertions(+), 70 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index ead94d95d..0e9131db1 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -1,8 +1,8 @@ import React from 'react'; -import { arrayOf, bool, func, node, object, oneOf, shape, string } from 'prop-types'; +import { arrayOf, bool, func, node, object, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field, { validProps, hasDataInFields } from '../../Field'; +import Field, { hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; @@ -19,7 +19,6 @@ const SectionArticle = props => { title, ingress, background, - backgroundImage, callToAction, blocks, isInsideContainer, @@ -31,10 +30,6 @@ const SectionArticle = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - // Find background color if it is included - const colorProp = validProps(background, fieldOptions); - const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; @@ -43,8 +38,7 @@ const SectionArticle = props => { id={sectionId} className={className} rootClassName={rootClassName} - style={backgroundColorMaybe} - backgroundImage={backgroundImage} + background={background} options={fieldOptions} > {hasHeaderFields ? ( @@ -63,13 +57,6 @@ const SectionArticle = props => { ); }; -const propTypeBlock = shape({ - blockId: string.isRequired, - blockType: oneOf(['default-block']).isRequired, - // Plus all kind of unknown fields. - // Section doesn't really need to care about those -}); - const propTypeOption = shape({ fieldComponents: shape({ component: node, pickValidProps: func }), }); @@ -104,7 +91,7 @@ SectionArticle.propTypes = { background: object, backgroundImage: object, callToAction: object, - blocks: arrayOf(propTypeBlock), + blocks: arrayOf(object), isInsideContainer: bool, options: propTypeOption, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index 49822d2ff..97689716b 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -1,7 +1,7 @@ import React from 'react'; -import { arrayOf, func, node, number, object, oneOf, shape, string } from 'prop-types'; +import { arrayOf, func, node, number, object, shape, string } from 'prop-types'; -import Field, { validProps, hasDataInFields } from '../../Field'; +import Field, { hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; @@ -36,7 +36,6 @@ const SectionCarousel = props => { title, ingress, background, - backgroundImage, callToAction, blocks, options, @@ -47,10 +46,6 @@ const SectionCarousel = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - // Find background color if it is included - const colorProp = validProps(background, fieldOptions); - const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; @@ -59,8 +54,7 @@ const SectionCarousel = props => { id={sectionId} className={className} rootClassName={rootClassName} - style={backgroundColorMaybe} - backgroundImage={backgroundImage} + background={background} options={fieldOptions} > {hasHeaderFields ? ( @@ -85,13 +79,6 @@ const SectionCarousel = props => { ); }; -const propTypeBlock = shape({ - blockId: string.isRequired, - blockType: oneOf(['default-block']).isRequired, - // Plus all kind of unknown fields. - // Section doesn't really need to care about those -}); - const propTypeOption = shape({ fieldComponents: shape({ component: node, pickValidProps: func }), }); @@ -127,7 +114,7 @@ SectionCarousel.propTypes = { background: object, backgroundImage: object, callToAction: object, - blocks: arrayOf(propTypeBlock), + blocks: arrayOf(object), options: propTypeOption, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js index f60093e53..043889c73 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js @@ -1,8 +1,8 @@ import React from 'react'; -import { arrayOf, bool, func, node, number, object, oneOf, shape, string } from 'prop-types'; +import { arrayOf, bool, func, node, number, object, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field, { validProps, hasDataInFields } from '../../Field'; +import Field, { hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; @@ -36,7 +36,6 @@ const SectionColumns = props => { title, ingress, background, - backgroundImage, callToAction, blocks, isInsideContainer, @@ -48,10 +47,6 @@ const SectionColumns = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - // Find background color if it is included - const colorProp = validProps(background, fieldOptions); - const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; @@ -60,8 +55,7 @@ const SectionColumns = props => { id={sectionId} className={className} rootClassName={rootClassName} - style={backgroundColorMaybe} - backgroundImage={backgroundImage} + background={background} options={fieldOptions} > {hasHeaderFields ? ( @@ -89,13 +83,6 @@ const SectionColumns = props => { ); }; -const propTypeBlock = shape({ - blockId: string.isRequired, - blockType: oneOf(['default-block']).isRequired, - // Plus all kind of unknown fields. - // Section doesn't really need to care about those -}); - const propTypeOption = shape({ fieldComponents: shape({ component: node, pickValidProps: func }), }); @@ -132,7 +119,7 @@ SectionColumns.propTypes = { background: object, backgroundImage: object, callToAction: object, - blocks: arrayOf(propTypeBlock), + blocks: arrayOf(object), isInsideContainer: bool, options: propTypeOption, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js index 485874d33..ab0872700 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -1,16 +1,16 @@ import React from 'react'; -import { arrayOf, bool, func, node, object, oneOf, shape, string } from 'prop-types'; +import { arrayOf, bool, func, node, object, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field, { validProps, hasDataInFields } from '../../Field'; +import Field, { hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; import css from './SectionFeatures.module.css'; -// Section component that shows features -// Blocks are shown in a row-like way: +// Section component that shows features. +// Block content are shown in a row-like way: // [image] text // text [image] // [image] text @@ -23,7 +23,6 @@ const SectionFeatures = props => { title, ingress, background, - backgroundImage, callToAction, blocks, isInsideContainer, @@ -35,10 +34,6 @@ const SectionFeatures = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - // Find background color if it is included - const colorProp = validProps(background, fieldOptions); - const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; @@ -47,8 +42,7 @@ const SectionFeatures = props => { id={sectionId} className={className} rootClassName={rootClassName} - style={backgroundColorMaybe} - backgroundImage={backgroundImage} + background={background} options={fieldOptions} > {hasHeaderFields ? ( @@ -72,13 +66,6 @@ const SectionFeatures = props => { ); }; -const propTypeBlock = shape({ - blockId: string.isRequired, - blockType: oneOf(['default-block']).isRequired, - // Plus all kind of unknown fields. - // Section doesn't really need to care about those -}); - const propTypeOption = shape({ fieldComponents: shape({ component: node, pickValidProps: func }), }); @@ -113,7 +100,7 @@ SectionFeatures.propTypes = { background: object, backgroundImage: object, callToAction: object, - blocks: arrayOf(propTypeBlock), + blocks: arrayOf(object), isInsideContainer: bool, options: propTypeOption, }; From 818a590c4c4186206cd19e8affeab9a7b2f94049 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 17:45:14 +0300 Subject: [PATCH 34/99] Remove deprecated BackgroundImage component --- .../PageBuilder/Field/Field.helpers.js | 13 ----- src/containers/PageBuilder/Field/Field.js | 48 ++++++++++------- .../PageBuilder/Primitives/Image/Image.js | 53 +------------------ .../Primitives/Image/Image.module.css | 8 +++ .../PageBuilder/Primitives/Image/index.js | 2 +- 5 files changed, 40 insertions(+), 84 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index f178ead7a..17c242fd3 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -92,19 +92,6 @@ export const exposeImageProps = data => { return isValidImage ? { alt, image: sanitizedImage } : {}; }; -/** - * Exposes "color" property, if it contains hexadecimal string like "#FF0000" or "#F00". - * - * @param {Object} data E.g. "{ type: 'hexColor', color: '#FFFFFF' }" - * @returns object containing color prop. - */ -export const exposeColorProps = data => { - const color = data?.color; - const re = new RegExp('^#([0-9a-f]{3}){1,2}$', 'i'); - const isValidColor = typeof color === 'string' && re.test(color); - return isValidColor ? { color } : {}; -}; - /** * Helper that exposes "color" value, if it contains hexadecimal string like "#FF0000" or "#F00". * diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index b4b8a24ea..9fb67d9d7 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -10,7 +10,7 @@ import { Ingress } from '../Primitives/Ingress'; import { P } from '../Primitives/P'; import { Code, CodeBlock } from '../Primitives/Code'; import { Link } from '../Primitives/Link'; -import { MarkdownImage, BackgroundImage, FieldImage } from '../Primitives/Image'; +import { MarkdownImage, FieldImage } from '../Primitives/Image'; import { CustomBackground } from '../Primitives/CustomBackground'; import renderMarkdown from '../markdownProcessor'; @@ -47,7 +47,6 @@ const defaultFieldComponents = { externalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, internalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, image: { component: FieldImage, pickValidProps: exposeImageProps }, - backgroundImage: { component: BackgroundImage, pickValidProps: exposeImageProps }, customBackground: { component: CustomBackground, pickValidProps: exposeCustomBackgroundProps }, // markdown content field is pretty complex component @@ -132,13 +131,15 @@ export const hasDataInFields = (fields, fieldOptions) => { // Field selector // //////////////////// +const isEmpty = obj => Object.keys(obj).length === 0; + // Generic field component that picks a specific UI component based on 'type' const Field = props => { const { data, options: fieldOptions, ...propsFromParent } = props; // Check the data and pick valid props only const validPropsFromData = validProps(data, fieldOptions); - const hasValidProps = validPropsFromData && Object.keys(validPropsFromData).length > 0; + const hasValidProps = validPropsFromData && !isEmpty(validPropsFromData); // Config contains component, pickValidProps, and potentially also options. // E.g. markdown has options.components to override default elements @@ -178,24 +179,34 @@ const propTypeLink = shape({ label: string.isRequired, href: string.isRequired, }); + const propTypeImageAsset = shape({ - type: oneOf(['image', 'backgroundImage']).isRequired, - alt: string.isRequired, - image: shape({ - id: string.isRequired, - type: oneOf(['imageAsset']).isRequired, - attributes: shape({ - variants: objectOf( - shape({ - width: number.isRequired, - height: number.isRequired, - url: string.isRequired, - }) - ).isRequired, - }).isRequired, + id: string.isRequired, + type: oneOf(['imageAsset']).isRequired, + attributes: shape({ + variants: objectOf( + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ).isRequired, }).isRequired, }); +const propTypeImage = shape({ + type: oneOf(['image']).isRequired, + alt: string.isRequired, + image: propTypeImageAsset.isRequired, +}); + +const propTypeCustomBackground = shape({ + type: oneOf(['customBackground']).isRequired, + color: string.isRequired, + textColor: string.isRequired, + backgroundImage: propTypeImageAsset.isRequired, +}); + const propTypeOption = shape({ fieldComponents: shape({ component: node, pickValidProps: func }), }); @@ -215,7 +226,8 @@ Field.propTypes = { propTypeTextContent, propTypeColor, propTypeLink, - propTypeImageAsset, + propTypeImage, + propTypeCustomBackground, propTypeEmptyObject, ]), options: propTypeOption, diff --git a/src/containers/PageBuilder/Primitives/Image/Image.js b/src/containers/PageBuilder/Primitives/Image/Image.js index c3316318a..9268b6ff5 100644 --- a/src/containers/PageBuilder/Primitives/Image/Image.js +++ b/src/containers/PageBuilder/Primitives/Image/Image.js @@ -1,8 +1,7 @@ import React from 'react'; -import { func, node, number, objectOf, oneOf, oneOfType, shape, string } from 'prop-types'; +import { number, objectOf, oneOf, shape, string } from 'prop-types'; import classNames from 'classnames'; -import { types as sdkTypes } from '../../../../util/sdkLoader'; import { AspectRatioWrapper, ResponsiveImage } from '../../../../components/index.js'; import css from './Image.module.css'; @@ -30,56 +29,6 @@ MarkdownImage.propTypes = { alt: string, }; -// BackgroundImage doesn't have enforcable aspectratio -export const BackgroundImage = React.forwardRef((props, ref) => { - const { className, rootClassName, alt, image, sizes, ...otherProps } = props; - - const { variants } = image?.attributes || {}; - const variantNames = Object.keys(variants); - - const classes = classNames(rootClassName || css.backgroundImage, className); - return ( - <ResponsiveImage - className={classes} - ref={ref} - alt={alt} - image={image} - variants={variantNames} - sizes={sizes} - {...otherProps} - /> - ); -}); - -BackgroundImage.displayName = 'BackgroundImage'; - -BackgroundImage.defaultProps = { - rootClassName: null, - className: null, - alt: 'image', - sizes: null, -}; - -BackgroundImage.propTypes = { - rootClassName: string, - className: string, - alt: string, - image: shape({ - id: string.isRequired, - type: oneOf(['imageAsset']).isRequired, - attributes: shape({ - variants: objectOf( - shape({ - width: number.isRequired, - height: number.isRequired, - url: string.isRequired, - }) - ).isRequired, - }).isRequired, - }).isRequired, - sizes: string, -}; - // Image as a Field (by default these are only allowed inside a block). export const FieldImage = React.forwardRef((props, ref) => { const { className, rootClassName, alt, image, sizes, ...otherProps } = props; diff --git a/src/containers/PageBuilder/Primitives/Image/Image.module.css b/src/containers/PageBuilder/Primitives/Image/Image.module.css index ba7bb772f..56a40ee01 100644 --- a/src/containers/PageBuilder/Primitives/Image/Image.module.css +++ b/src/containers/PageBuilder/Primitives/Image/Image.module.css @@ -4,6 +4,14 @@ object-fit: cover; } +.backgroundImageWrapper { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + .backgroundImage { object-fit: cover; width: 100%; diff --git a/src/containers/PageBuilder/Primitives/Image/index.js b/src/containers/PageBuilder/Primitives/Image/index.js index c5862f68e..2efd3e6cf 100644 --- a/src/containers/PageBuilder/Primitives/Image/index.js +++ b/src/containers/PageBuilder/Primitives/Image/index.js @@ -1 +1 @@ -export { MarkdownImage, BackgroundImage, FieldImage } from './Image'; +export { MarkdownImage, FieldImage } from './Image'; From c24f7646875514e6aad75ec7d242f8642178908c Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 22:19:15 +0300 Subject: [PATCH 35/99] Check possible isDarkTheme through background key instead of top-level textColor --- src/containers/PageBuilder/SectionBuilder/SectionBuilder.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index a8734d803..353d5f893 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -61,7 +61,8 @@ const SectionBuilder = props => { {sections.map(section => { const Section = getComponent(section.sectionType); // If the default "dark" theme should be applied - const isDarkTheme = section.textColor === 'light'; + // By default, this information is stored to customBackground field + const isDarkTheme = section?.background?.textColor === 'light'; const classes = classNames({ [css.darkTheme]: isDarkTheme }); if (Section) { From 9a25dd714aca6b32239fe90c05f15a139725eb5d Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 22:20:17 +0300 Subject: [PATCH 36/99] Remove hexColor field in favor of 'customBackground' --- src/containers/LandingPage/FallbackPage.js | 2 +- .../PageBuilder/Field/Field.helpers.js | 8 +- .../PageBuilder/Field/Field.helpers.test.js | 141 ++++++++++++++++-- src/containers/PageBuilder/Field/Field.js | 15 +- src/containers/PageBuilder/Field/README.md | 3 - .../PageBuilder/Markdown.example.js | 4 +- .../PageBuilder/PageBuilder.example.js | 2 +- .../SectionBuilder/SectionBuilder.example.js | 8 +- .../PrivacyPolicyPage/FallbackPage.js | 2 +- .../TermsOfServicePage/FallbackPage.js | 2 +- 10 files changed, 142 insertions(+), 45 deletions(-) diff --git a/src/containers/LandingPage/FallbackPage.js b/src/containers/LandingPage/FallbackPage.js index a9f543aa0..947b56c69 100644 --- a/src/containers/LandingPage/FallbackPage.js +++ b/src/containers/LandingPage/FallbackPage.js @@ -8,7 +8,7 @@ export const fallbackSections = { { sectionType: 'features', sectionId: 'hero', - background: { type: 'hexColor', color: '#ffff00' }, + background: { type: 'customBackground', color: '#ffff00' }, // backgroundImage: { // type: 'image', // alt: 'Background image', diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index 17c242fd3..28723bf5c 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -98,7 +98,7 @@ export const exposeImageProps = data => { * @param {String} data E.g. "#FFFFFF" * @returns Object containing valid color prop. */ - const exposeColorValue = color => { +const exposeColorValue = color => { const re = new RegExp('^#([0-9a-f]{3}){1,2}$', 'i'); const isValidColor = typeof color === 'string' && re.test(color); return isValidColor ? color : null; @@ -109,10 +109,8 @@ export const exposeImageProps = data => { * if backgroundImage contains imageAsset entity and * color contains hexadecimal string like "#FF0000" or "#F00". * - * @param {ImageAsset} data. - * @param {Object} data E.g. "{ type: 'hexColor', color: '#FFFFFF' }" - * @param {textColor} data E.g. "{ type: 'hexColor', color: '#FFFFFF' }" - * @returns object containing color prop. + * @param {Object} data E.g. "{ type: 'customBackground', backgroundImage: imageAssetRef, color: '#000000', textColor: '#FFFFFF' }" + * @returns object containing valid data. */ export const exposeCustomBackgroundProps = data => { const { backgroundImage, color, textColor, alt } = data; diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index a03eb563c..d8e45cca0 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -3,7 +3,7 @@ import { exposeContentString, exposeLinkProps, exposeImageProps, - exposeColorProps, + exposeCustomBackgroundProps, } from './Field.helpers'; describe('Field helpers', () => { @@ -149,22 +149,133 @@ describe('Field helpers', () => { }); }); - describe('exposeColorProps(data)', () => { - it('should return only "color" prop containing valid hexadecimal color code', () => { - expect(exposeColorProps({ color: '#FFAA00' })).toEqual({ color: '#FFAA00' }); - expect(exposeColorProps({ color: '#FA0' })).toEqual({ color: '#FA0' }); - expect(exposeColorProps({ color: '#000000' })).toEqual({ color: '#000000' }); + describe('exposeCustomBackgroundProps(data)', () => { + it('should return "color" prop containing valid hexadecimal color code', () => { + expect(exposeCustomBackgroundProps({ color: '#FFAA00' })).toEqual({ color: '#FFAA00' }); + expect(exposeCustomBackgroundProps({ color: '#FA0' })).toEqual({ color: '#FA0' }); + expect(exposeCustomBackgroundProps({ color: '#000000', foo: 'bar' })).toEqual({ + color: '#000000', + }); }); it('should return empty "color" prop if invalid hexadecimal color code was detected', () => { - expect(exposeColorProps({ color: '#FFAA0000' })).toEqual({}); - expect(exposeColorProps({ color: 'FA0' })).toEqual({}); - expect(exposeColorProps({ color: '000000' })).toEqual({}); - expect(exposeColorProps({ color: '#XX0000' })).toEqual({}); - expect(exposeColorProps({ color: '#FFAA0' })).toEqual({}); - expect(exposeColorProps({ color: 'rgb(100, 100, 100)' })).toEqual({}); - expect(exposeColorProps({ color: 'hsl(60 100% 50%)' })).toEqual({}); - expect(exposeColorProps({ color: 'hwb(90 10% 10%)' })).toEqual({}); - expect(exposeColorProps({ color: 'tomato' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: '#FFAA0000' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: 'FA0' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: '000000' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: '#XX0000' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: '#FFAA0' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: 'rgb(100, 100, 100)' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: 'hsl(60 100% 50%)' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: 'hwb(90 10% 10%)' })).toEqual({}); + expect(exposeCustomBackgroundProps({ color: 'tomato' })).toEqual({}); + }); + + it('should return "textColor" prop containing valid value (light or dark)', () => { + expect(exposeCustomBackgroundProps({ textColor: 'light' })).toEqual({ textColor: 'light' }); + expect(exposeCustomBackgroundProps({ textColor: 'dark' })).toEqual({ textColor: 'dark' }); + }); + it('should return empty "textColor" prop if invalid hexadecimal color code was detected', () => { + expect(exposeCustomBackgroundProps({ textColor: 'blaa' })).toEqual({}); + }); + + it('should return "backgroundImage" prop containing valid imageAsset', () => { + const backgroundImage = { + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/100/100`, + width: 100, + height: 100, + }, + square2x: { + url: `https://picsum.photos/200/200`, + width: 200, + height: 200, + }, + }, + }, + }; + const alt = 'gb'; + expect(exposeCustomBackgroundProps({ backgroundImage })).toEqual({ backgroundImage }); + expect(exposeCustomBackgroundProps({ backgroundImage, alt })).toEqual({ + backgroundImage, + alt, + }); + }); + + it('should return empty "backgroundImage" prop if invalid value is passed', () => { + const backgroundImageWrongType = { + id: 'image', + type: 'blaa', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/100/100`, + width: 100, + height: 100, + }, + }, + }, + }; + const backgroundImageNoHeight = { + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/100/100`, + width: 100, + // height: 100, + }, + }, + }, + }; + const alt = 'gb'; + const backgroundImage = backgroundImageWrongType; + expect(exposeCustomBackgroundProps({ backgroundImage })).toEqual({}); + expect(exposeCustomBackgroundProps({ backgroundImage, alt })).toEqual({}); + expect(exposeCustomBackgroundProps({ backgroundImage, color: '#FFAA00' })).toEqual({}); + expect(exposeCustomBackgroundProps({ backgroundImage: backgroundImageNoHeight })).toEqual({}); + }); + + it('should return partial prop if one of the props is invalid', () => { + const backgroundImageNoHeight = { + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/100/100`, + width: 100, + //height: 100, + }, + }, + }, + }; + const backgroundImage = { + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + square1x: { + url: `https://picsum.photos/100/100`, + width: 100, + height: 100, + }, + }, + }, + }; + + const testA = exposeCustomBackgroundProps({ + backgroundImage: backgroundImageNoHeight, + color: '#FFAA00', + }); + expect(testA).toEqual({ color: '#FFAA00' }); + + const alt = 'gb'; + const testB = exposeCustomBackgroundProps({ backgroundImage, alt, color: 'tomato' }); + expect(testB).toEqual({ backgroundImage, alt }); }); }); }); diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index 9fb67d9d7..5d554bdd6 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -21,7 +21,6 @@ import { exposeLinkProps, exposeCustomBackgroundProps, exposeImageProps, - exposeColorProps, } from './Field.helpers'; //////////////////////// @@ -73,8 +72,6 @@ const defaultFieldComponents = { }, }, }, - // hexColor doesn't render component: it's used as an inlined background-color for section component - hexColor: { pickValidProps: exposeColorProps }, }; ////////////////// @@ -169,11 +166,6 @@ const propTypeTextContent = shape({ ]).isRequired, content: string.isRequired, }); -const propTypeColor = shape({ - type: oneOf(['hexColor']).isRequired, - color: string.isRequired, - href: string.isRequired, -}); const propTypeLink = shape({ type: oneOf(['externalButtonLink', 'internalButtonLink']).isRequired, label: string.isRequired, @@ -202,9 +194,9 @@ const propTypeImage = shape({ const propTypeCustomBackground = shape({ type: oneOf(['customBackground']).isRequired, - color: string.isRequired, - textColor: string.isRequired, - backgroundImage: propTypeImageAsset.isRequired, + color: string, + textColor: string, + backgroundImage: propTypeImageAsset, }); const propTypeOption = shape({ @@ -224,7 +216,6 @@ Field.defaultProps = { Field.propTypes = { data: oneOfType([ propTypeTextContent, - propTypeColor, propTypeLink, propTypeImage, propTypeCustomBackground, diff --git a/src/containers/PageBuilder/Field/README.md b/src/containers/PageBuilder/Field/README.md index 1bc85e514..e155bc1b2 100644 --- a/src/containers/PageBuilder/Field/README.md +++ b/src/containers/PageBuilder/Field/README.md @@ -23,9 +23,6 @@ const defaultFieldComponents = { paragraph: { component: Ingress, pickValidProps: exposeContentAsChildren }, externalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, image: { component: FieldImage, pickValidProps: exposeImageProps }, - // In some cases, the data is used without a renderable component. - // Data for "background-color" in _style_ prop could be an example of that. - hexColor: { pickValidProps: exposeColorProps }, }; ``` diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index 4197eb062..baf8124ed 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -513,7 +513,7 @@ const SectionLinksOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-3-dark', numColumns: 2, - background: { type: 'hexColor', color: '#000000' }, + background: { type: 'customBackground', color: '#000000' }, textColor: 'light', title: { type: 'heading2', content: 'Links on dark theme' }, blocks: [ @@ -536,7 +536,7 @@ const SectionCodeOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - background: { type: 'hexColor', color: '#000000' }, + background: { type: 'customBackground', color: '#000000' }, textColor: 'light', title: { type: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index 134ab4c6a..ad63324dd 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -167,7 +167,7 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns-section-1', numColumns: 2, - background: { type: 'hexColor', color: hexYellow }, + background: { type: 'customBackground', color: hexYellow }, title: { type: 'heading2', content: '2 Columns' }, ingress: { type: 'heading2', diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index ac1b5c2c3..e0ccad2f8 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -373,7 +373,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block', numColumns: 1, - background: { type: 'hexColor', color: hexYellow }, + background: { type: 'customBackground', color: hexYellow }, title: { type: 'heading2', content: 'One Column, No Blocks' }, ingress: { type: 'paragraph', @@ -384,7 +384,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-dark', numColumns: 1, - background: { type: 'hexColor', color: hexBlack }, + background: { type: 'customBackground', color: hexBlack }, textColor: 'light', title: { type: 'heading2', content: 'One Column, No Blocks' }, ingress: { @@ -401,7 +401,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-bg-img', numColumns: 1, - background: { type: 'hexColor', color: hexYellow }, + background: { type: 'customBackground', color: hexYellow }, backgroundImage: { type: 'image', alt: 'Background image', @@ -492,7 +492,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-2-dark', numColumns: 2, - background: { type: 'hexColor', color: hexBlack }, + background: { type: 'customBackground', color: hexBlack }, textColor: 'light', title: { type: 'heading2', content: '2 Columns, Dark' }, ingress: { diff --git a/src/containers/PrivacyPolicyPage/FallbackPage.js b/src/containers/PrivacyPolicyPage/FallbackPage.js index 199ea602b..59ef5c3f4 100644 --- a/src/containers/PrivacyPolicyPage/FallbackPage.js +++ b/src/containers/PrivacyPolicyPage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'privacy', - background: { type: 'hexColor', color: '#ffffff' }, + background: { type: 'customBackground', color: '#ffffff' }, title: { type: 'heading1', content: 'Privacy Policy' }, blocks: [ { diff --git a/src/containers/TermsOfServicePage/FallbackPage.js b/src/containers/TermsOfServicePage/FallbackPage.js index f4dd3ccc3..3d7b2cfc4 100644 --- a/src/containers/TermsOfServicePage/FallbackPage.js +++ b/src/containers/TermsOfServicePage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'terms', - background: { type: 'hexColor', color: '#ffffff' }, + background: { type: 'customBackground', color: '#ffffff' }, title: { type: 'heading1', content: 'Terms of Service' }, blocks: [ { From 0f3fb2b27c361e74f70c26bec119ddc6714ed693 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 20 Oct 2022 22:13:42 +0300 Subject: [PATCH 37/99] Add initial version of Youtube video support --- server/csp.js | 5 +- .../PageBuilder/Field/Field.helpers.js | 28 +++++++++++ .../PageBuilder/Field/Field.helpers.test.js | 14 ++++++ src/containers/PageBuilder/Field/Field.js | 3 ++ .../Primitives/YoutubeEmbed/YoutubeEmbed.js | 46 +++++++++++++++++++ .../YoutubeEmbed/YoutubeEmbed.module.css | 12 +++++ .../Primitives/YoutubeEmbed/index.js | 1 + 7 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js create mode 100644 src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css create mode 100644 src/containers/PageBuilder/Primitives/YoutubeEmbed/index.js diff --git a/server/csp.js b/server/csp.js index 47fc51818..ae4acf7f4 100644 --- a/server/csp.js +++ b/server/csp.js @@ -40,7 +40,7 @@ const defaultDirectives = { '*.stripe.com', ], fontSrc: [self, data, 'assets-sharetribecom.sharetribe.com', 'fonts.gstatic.com'], - frameSrc: [self, '*.stripe.com'], + frameSrc: [self, '*.stripe.com', '*.youtube-nocookie.com'], imgSrc: [ self, data, @@ -65,6 +65,9 @@ const defaultDirectives = { 'www.google-analytics.com', 'stats.g.doubleclick.net', + // Youtube (static image) + '*.ytimg.com', + '*.stripe.com', ], scriptSrc: [ diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index 28723bf5c..c87d129a9 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -150,3 +150,31 @@ export const exposeCustomBackgroundProps = data => { ...textColorMaybe, }; }; + +/** + * Exposes "youtubeVideoId" and "aspectRatio", + * if they meet the regexp rules. + * + * @param {Object} data E.g. "{ type: 'link', label: 'my title', href: 'https://my.domain.com' }" + * @returns object containing children and href. + */ +export const exposeYoutubeProps = data => { + const { youtubeVideoId, aspectRatio } = data; + const isString = str => typeof str === 'string' && str?.length > 0; + + const hasYoutubeVideoId = + isString(youtubeVideoId) && + youtubeVideoId.length < 12 && + youtubeVideoId.match(/^[a-zA-Z0-9_-]+$/i); + const cleanYoutubeVideoId = hasYoutubeVideoId ? encodeURIComponent(youtubeVideoId) : null; + + const hasAspectRatio = isString(aspectRatio) && aspectRatio.match(/^(\d+)\/(\d+)+$/); + const aspectRatioMaybe = hasAspectRatio ? { aspectRatio } : {}; + + return cleanYoutubeVideoId + ? { + youtubeVideoId: cleanYoutubeVideoId, + ...aspectRatioMaybe, + } + : {}; +}; diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index d8e45cca0..785bfe708 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -4,6 +4,7 @@ import { exposeLinkProps, exposeImageProps, exposeCustomBackgroundProps, + exposeYoutubeProps, } from './Field.helpers'; describe('Field helpers', () => { @@ -278,4 +279,17 @@ describe('Field helpers', () => { expect(testB).toEqual({ backgroundImage, alt }); }); }); + + describe('exposeYoutubeProps(data)', () => { + it('should return "youtubeVideoId" prop ', () => { + const youtubeVideoId = '9RQlikX4vvw'; + expect(exposeYoutubeProps({ youtubeVideoId })).toEqual({ youtubeVideoId }); + }); + it('should return empty object if invalid "youtubeVideoId" was detected', () => { + expect(exposeYoutubeProps({ youtubeVideoId: '9RQli?kX4vvw' })).toEqual({}); + expect(exposeYoutubeProps({ youtubeVideoId: '9RQli&kX4vvw' })).toEqual({}); + expect(exposeYoutubeProps({ youtubeVideoId: '9RQli<kX4vvw' })).toEqual({}); + expect(exposeYoutubeProps({ youtubeVideoId: '9RQli>kX4vvw' })).toEqual({}); + }); + }); }); diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index 5d554bdd6..2ce63b2c0 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -12,6 +12,7 @@ import { Code, CodeBlock } from '../Primitives/Code'; import { Link } from '../Primitives/Link'; import { MarkdownImage, FieldImage } from '../Primitives/Image'; import { CustomBackground } from '../Primitives/CustomBackground'; +import { YoutubeEmbed } from '../Primitives/YoutubeEmbed'; import renderMarkdown from '../markdownProcessor'; @@ -21,6 +22,7 @@ import { exposeLinkProps, exposeCustomBackgroundProps, exposeImageProps, + exposeYoutubeProps, } from './Field.helpers'; //////////////////////// @@ -47,6 +49,7 @@ const defaultFieldComponents = { internalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, image: { component: FieldImage, pickValidProps: exposeImageProps }, customBackground: { component: CustomBackground, pickValidProps: exposeCustomBackgroundProps }, + youtube: { component: YoutubeEmbed, pickValidProps: exposeYoutubeProps }, // markdown content field is pretty complex component markdown: { diff --git a/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js new file mode 100644 index 000000000..6bd4200e1 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { string } from 'prop-types'; +import classNames from 'classnames'; + +import { AspectRatioWrapper } from '../../../../components/index.js'; + +import css from './YoutubeEmbed.module.css'; + +const RADIX = 10; + +export const YoutubeEmbed = React.forwardRef((props, ref) => { + const { className, rootClassName, youtubeVideoId, aspectRatio } = props; + const hasSlash = aspectRatio.indexOf('/') > 0; + const [aspectWidth, aspectHeight] = hasSlash ? aspectRatio.split('/') : [16, 9]; + const width = Number.parseInt(aspectWidth, RADIX); + const height = Number.parseInt(aspectHeight, RADIX); + const classes = classNames(rootClassName || css.video, className); + + return ( + <AspectRatioWrapper className={classes} width={width} height={height}> + <iframe + src={`https://www.youtube-nocookie.com/embed/${youtubeVideoId}`} + className={css.iframe} + frameBorder="0" + allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" + allowFullScreen + title="Embedded youtube" + /> + </AspectRatioWrapper> + ); +}); + +YoutubeEmbed.displayName = 'YoutubeEmbed'; + +YoutubeEmbed.defaultProps = { + rootClassName: null, + className: null, + aspectRatio: '16/9', +}; + +YoutubeEmbed.propTypes = { + rootClassName: string, + className: string, + youtubeVideoId: string.isRequired, + aspectRatio: string, +}; diff --git a/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css new file mode 100644 index 000000000..57f9c93a6 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css @@ -0,0 +1,12 @@ +.video { + position: relative; +} +.iframe { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + width: 100%; + height: 100%; +} diff --git a/src/containers/PageBuilder/Primitives/YoutubeEmbed/index.js b/src/containers/PageBuilder/Primitives/YoutubeEmbed/index.js new file mode 100644 index 000000000..ee7b61017 --- /dev/null +++ b/src/containers/PageBuilder/Primitives/YoutubeEmbed/index.js @@ -0,0 +1 @@ +export { YoutubeEmbed } from './YoutubeEmbed'; From 983f601758bb01d6268d5cf3e9a2c6db7bb76238 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 30 Aug 2022 14:19:29 +0300 Subject: [PATCH 38/99] TermsOfServicePage is built with PageBuilder and Asset Delivery API. It defaults to fallback page --- src/components/PrivacyPolicy/PrivacyPolicy.js | 76 ------------------- .../PrivacyPolicy/PrivacyPolicy.module.css | 39 ---------- .../TermsOfService/TermsOfService.js | 76 ------------------- src/components/index.js | 1 - 4 files changed, 192 deletions(-) delete mode 100644 src/components/PrivacyPolicy/PrivacyPolicy.js delete mode 100644 src/components/PrivacyPolicy/PrivacyPolicy.module.css delete mode 100644 src/components/TermsOfService/TermsOfService.js diff --git a/src/components/PrivacyPolicy/PrivacyPolicy.js b/src/components/PrivacyPolicy/PrivacyPolicy.js deleted file mode 100644 index 979f16e01..000000000 --- a/src/components/PrivacyPolicy/PrivacyPolicy.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -import css from './PrivacyPolicy.module.css'; - -const PrivacyPolicy = props => { - const { rootClassName, className } = props; - const classes = classNames(rootClassName || css.root, className); - - // prettier-ignore - return ( - <div className={classes}> - <p className={css.lastUpdated}>Last updated: October 30, 2017</p> - - <p> - Thank you for using Saunatime! Every marketplace business needs Terms of Service and - Privacy Policy agreements. To help you launch your marketplace faster, we've compiled - two templates you can use as a baseline for the agreements between your online marketplace - business and its users. You can access these templates at - https://www.sharetribe.com/docs/operator-guides/free-templates/ - </p> - - <h2>1 Lorem ipsum dolor sit amet</h2> - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco - laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in - voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat - cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - </p> - - <h2>2 Sed ut perspiciatis unde</h2> - <p> - Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque - laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi - architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit - aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione - voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, - consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et - dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum - exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi - consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil - molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? - </p> - - <h2>3 At vero eos et accusamus</h2> - <p> - At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium - voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati - cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id - est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam - libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod - maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. - Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut - et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a - sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis - doloribus asperiores repellat - </p> - </div> - ); -}; - -PrivacyPolicy.defaultProps = { - rootClassName: null, - className: null, -}; - -const { string } = PropTypes; - -PrivacyPolicy.propTypes = { - rootClassName: string, - className: string, -}; - -export default PrivacyPolicy; diff --git a/src/components/PrivacyPolicy/PrivacyPolicy.module.css b/src/components/PrivacyPolicy/PrivacyPolicy.module.css deleted file mode 100644 index 16e59ac55..000000000 --- a/src/components/PrivacyPolicy/PrivacyPolicy.module.css +++ /dev/null @@ -1,39 +0,0 @@ -@import '../../styles/customMediaQueries.css'; - -.root { - & p { - font-weight: var(--fontWeightMedium); - font-size: 15px; - line-height: 24px; - letter-spacing: 0; - /* margin-top + n * line-height + margin-bottom => x * 6px */ - margin-top: 12px; - margin-bottom: 12px; - - @media (--viewportMedium) { - font-weight: var(--fontWeightMedium); - /* margin-top + n * line-height + margin-bottom => x * 8px */ - margin-top: 17px; - margin-bottom: 15px; - } - } - & h2 { - /* Adjust heading margins to work with the reduced body font size */ - margin: 29px 0 13px 0; - - @media (--viewportMedium) { - margin: 32px 0 0 0; - } - } -} - -.lastUpdated { - composes: marketplaceBodyFontStyles from global; - margin-top: 0; - margin-bottom: 55px; - - @media (--viewportMedium) { - margin-top: 0; - margin-bottom: 54px; - } -} diff --git a/src/components/TermsOfService/TermsOfService.js b/src/components/TermsOfService/TermsOfService.js deleted file mode 100644 index 09c8fa486..000000000 --- a/src/components/TermsOfService/TermsOfService.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -import css from './TermsOfService.module.css'; - -const TermsOfService = props => { - const { rootClassName, className } = props; - const classes = classNames(rootClassName || css.root, className); - - // prettier-ignore - return ( - <div className={classes}> - <p className={css.lastUpdated}>Last updated: October 30, 2017</p> - - <p> - Thank you for using Saunatime! Every marketplace business needs Terms of Service and - Privacy Policy agreements. To help you launch your marketplace faster, we've compiled - two templates you can use as a baseline for the agreements between your online marketplace - business and its users. You can access these templates at - https://www.sharetribe.com/docs/operator-guides/free-templates/ - </p> - - <h2>1 Lorem ipsum dolor sit amet</h2> - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco - laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in - voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat - cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - </p> - - <h2>2 Sed ut perspiciatis unde</h2> - <p> - Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque - laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi - architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit - aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione - voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, - consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et - dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum - exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi - consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil - molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? - </p> - - <h2>3 At vero eos et accusamus</h2> - <p> - At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium - voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati - cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id - est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam - libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod - maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. - Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut - et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a - sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis - doloribus asperiores repellat - </p> - </div> - ); -}; - -TermsOfService.defaultProps = { - rootClassName: null, - className: null, -}; - -const { string } = PropTypes; - -TermsOfService.propTypes = { - rootClassName: string, - className: string, -}; - -export default TermsOfService; diff --git a/src/components/index.js b/src/components/index.js index ea4bde4f7..3cefb9191 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -157,7 +157,6 @@ export { default as LayoutWrapperAccountSettingsSideNav } from './LayoutWrapperA export {default as LoadableComponentErrorBoundary } from './LoadableComponentErrorBoundary/LoadableComponentErrorBoundary' export { default as ModalMissingInformation } from './ModalMissingInformation/ModalMissingInformation'; export { default as ReviewModal } from './ReviewModal/ReviewModal'; -export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy'; export { default as EditListingAvailabilityPanel } from './EditListingAvailabilityPanel/EditListingAvailabilityPanel'; export { default as EditListingDescriptionPanel } from './EditListingDescriptionPanel/EditListingDescriptionPanel'; export { default as EditListingFeaturesPanel } from './EditListingFeaturesPanel/EditListingFeaturesPanel'; From a448c4c65c99601b7c7cc5abe91abd6c69c73081 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 17:57:00 +0200 Subject: [PATCH 39/99] Edit LandingPage/Fallback (Janne's commit splitted) changes to LandingPage FallBack --- src/containers/LandingPage/FallbackPage.js | 91 +++++-------------- .../LandingPage/FallbackPage.module.css | 5 + 2 files changed, 30 insertions(+), 66 deletions(-) create mode 100644 src/containers/LandingPage/FallbackPage.module.css diff --git a/src/containers/LandingPage/FallbackPage.js b/src/containers/LandingPage/FallbackPage.js index 947b56c69..1eefa01fd 100644 --- a/src/containers/LandingPage/FallbackPage.js +++ b/src/containers/LandingPage/FallbackPage.js @@ -1,85 +1,44 @@ import React from 'react'; - import PageBuilder from '../PageBuilder/PageBuilder'; +import css from './FallbackPage.module.css'; // Create fallback content (array of sections) in page asset format: export const fallbackSections = { sections: [ { - sectionType: 'features', - sectionId: 'hero', - background: { type: 'customBackground', color: '#ffff00' }, - // backgroundImage: { - // type: 'image', - // alt: 'Background image', - // image: { - // id: 'image', - // type: 'imageAsset', - // attributes: { - // variants: { - // square1x: { - // url: `https://picsum.photos/400/400`, - // width: 400, - // height: 400, - // }, - // square2x: { - // url: `https://picsum.photos/800/800`, - // width: 800, - // height: 800, - // }, - // }, - // }, - // }, - // }, - blocks: [ - { - blockType: 'defaultBlock', - blockId: 'hero-content', - media: { - type: 'image', - alt: 'First image', - image: { - id: 'image', - type: 'imageAsset', - attributes: { - variants: { - square1x: { - url: `https://picsum.photos/400/400`, - width: 400, - height: 400, - }, - square2x: { - url: `https://picsum.photos/800/800`, - width: 800, - height: 800, - }, - }, - }, - }, - }, - title: { type: 'heading1', content: 'My marketplace' }, - text: { - type: 'markdown', - content: - '### My unique marketplace for booking listings\n### You can also list your services here!', - }, - callToAction: { - type: 'internalButtonLink', - href: '/s', - label: 'Browse marketplace', - }, - }, - ], + sectionType: 'customMaintenance', + sectionId: 'maintenance-mode', }, ], }; -// This is the fallback page, in case there's no Privacy Policy asset defined in Console. +const SectionMaintenanceMode = props => { + const { sectionId } = props; + + return ( + <section id={sectionId} className={css.root}> + <div className={css.content}> + <h1>Maintenance mode</h1> + <p> + The marketplace is not fully operational at the moment. Try refreshing the page and if + that does not solve the issue, contact the marketplace admins. + </p> + </div> + </section> + ); +}; + +// This is the fallback page, in case there's no Landing Page asset defined in Console. const FallbackPage = props => { const { title, description, schema, contentType } = props; return ( <PageBuilder pageAssetsData={fallbackSections} + options={{ + sectionComponents: { + customMaintenance: { component: SectionMaintenanceMode }, + }, + }} title={title} description={description} schema={schema} diff --git a/src/containers/LandingPage/FallbackPage.module.css b/src/containers/LandingPage/FallbackPage.module.css new file mode 100644 index 000000000..f48693806 --- /dev/null +++ b/src/containers/LandingPage/FallbackPage.module.css @@ -0,0 +1,5 @@ +.root { +} + +.content { +} From 2029eab46a2a15b421377ab0be959a76a5961c9d Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 17:56:28 +0200 Subject: [PATCH 40/99] add dark theme support, disable rendering empty divs and vertical spacing between elements (Janne's commit splitted) feedback reactions and fixes --- .../LandingPage/FallbackPage.module.css | 4 + .../BlockContainer/BlockContainer.module.css | 1 - .../BlockDefault/BlockDefault.module.css | 16 ++- .../Primitives/Heading/Heading.module.css | 71 +++++++++++- .../Primitives/Ingress/Ingress.module.css | 17 ++- .../Primitives/Link/Link.module.css | 16 +++ .../Primitives/List/List.module.css | 38 +++++- .../PageBuilder/Primitives/P/P.module.css | 19 ++- .../SectionArticle/SectionArticle.module.css | 4 +- .../SectionBuilder/SectionBuilder.module.css | 108 +++++++++++++++++- src/styles/marketplaceDefaults.css | 1 + 11 files changed, 280 insertions(+), 15 deletions(-) diff --git a/src/containers/LandingPage/FallbackPage.module.css b/src/containers/LandingPage/FallbackPage.module.css index f48693806..dec1d857e 100644 --- a/src/containers/LandingPage/FallbackPage.module.css +++ b/src/containers/LandingPage/FallbackPage.module.css @@ -1,5 +1,9 @@ .root { + padding: 20vh 36px; } .content { + text-align: center; + max-width: 650px; + margin: 0 auto; } diff --git a/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css b/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css index 1b421ac56..c3a2af639 100644 --- a/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css +++ b/src/containers/PageBuilder/BlockBuilder/BlockContainer/BlockContainer.module.css @@ -1,3 +1,2 @@ .root { - padding-bottom: calc(32px * 2); } diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css index e0c984208..dc7ac303e 100644 --- a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css @@ -5,9 +5,23 @@ width: 100%; background-color: var(--matterColorNegative); /* Loading state color for the images */ border-radius: 8px; - margin-bottom: 24px; + margin-bottom: 0; + + /* Don't render the div if there's no image to render */ + &:empty { + display: none; + } + + & + div { + margin-top: 20px; + } } .text { width: 100%; + + /* Don't render the div if there's no image to render */ + &:empty { + display: none; + } } diff --git a/src/containers/PageBuilder/Primitives/Heading/Heading.module.css b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css index d9782268b..fadd0fe0f 100644 --- a/src/containers/PageBuilder/Primitives/Heading/Heading.module.css +++ b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css @@ -6,24 +6,93 @@ .h5, .h6 { margin-top: 0; + margin-bottom: 0; line-height: 1.33; font-weight: bold; + color: var(--matterColorAlmostDark); } /* Specific styles */ .h1 { font-size: 40px; + + /* If ´& + *´ if used, margin-top needs !important to overwrite */ + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 24px; + } } .h2 { font-size: 30px; - margin-bottom: 19px; + + /* If ´& + *´ if used, margin-top needs !important to overwrite */ + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 16px; + } } .h3 { font-size: 24px; + + /* If ´& + *´ if used, margin-top needs !important to overwrite */ + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 8px; + } } .h4 { font-size: 21px; } + +.h4, +.h5, +.h6 { + /* If ´& + *´ if used, margin-top needs !important to overwrite */ + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 8px; + } +} diff --git a/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css index 836ab841e..7593452ff 100644 --- a/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css +++ b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css @@ -2,6 +2,21 @@ font-size: 18px; line-height: 1.66; margin-top: 0; - margin-bottom: 24px; + margin-bottom: 0; letter-spacing: 0; + + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 24px; + } } diff --git a/src/containers/PageBuilder/Primitives/Link/Link.module.css b/src/containers/PageBuilder/Primitives/Link/Link.module.css index a7d2375dd..7864cea47 100644 --- a/src/containers/PageBuilder/Primitives/Link/Link.module.css +++ b/src/containers/PageBuilder/Primitives/Link/Link.module.css @@ -1,3 +1,19 @@ .link { + display: inline-block; color: var(--marketplaceColor); + + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 24px; + } } diff --git a/src/containers/PageBuilder/Primitives/List/List.module.css b/src/containers/PageBuilder/Primitives/List/List.module.css index 05744e21e..359bbf6b2 100644 --- a/src/containers/PageBuilder/Primitives/List/List.module.css +++ b/src/containers/PageBuilder/Primitives/List/List.module.css @@ -1,8 +1,8 @@ .ul { display: block; list-style-type: disc; - margin-block-start: 1em; - margin-block-end: 1em; + margin-block-start: 0; + margin-block-end: 0; margin-inline-start: 0px; margin-inline-end: 0px; padding-inline-start: 40px; @@ -14,9 +14,38 @@ list-style-type: square; } } + + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 24px; + } } .ol { + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 24px; + } } .li { @@ -25,3 +54,8 @@ .li > p { margin: 0; } + +.ul + p, +.ol + p { + margin-top: 24px; +} diff --git a/src/containers/PageBuilder/Primitives/P/P.module.css b/src/containers/PageBuilder/Primitives/P/P.module.css index 9973bae63..4a4b84a9f 100644 --- a/src/containers/PageBuilder/Primitives/P/P.module.css +++ b/src/containers/PageBuilder/Primitives/P/P.module.css @@ -2,10 +2,21 @@ max-width: 80ch; line-height: 1.66; margin-top: 0; - margin-bottom: 24px; + margin-bottom: 0; letter-spacing: 0; -} -h2 + p { - margin-top: 16px; + & + p, + & + a, + & + ul, + & + ol, + & + code, + & + div, + & + h1, + & + h2, + & + h3, + & + h4, + & + h5, + & + h6 { + margin-top: 24px; + } } diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css index d47858ea8..5f8c5db69 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css @@ -1,10 +1,10 @@ .articleMain { - max-width: 720px; + max-width: calc(720px + (2 * 32px)); display: grid; grid-template-columns: repeat(1, 1fr); gap: 32px; margin: 0 auto; - padding: 32px; + padding: 0 32px; } .noSidePaddings { diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css index aede97d3b..da3fc89d4 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css @@ -6,9 +6,103 @@ display: grid; justify-content: start; margin: 0 auto; - padding: 0 32px 64px; + padding: 0 32px; position: relative; + & + div { + padding: 64px 32px 0 32px; + } + + & h1 + p, + & h1 + a, + & h1 + ul, + & h1 + ol, + & h1 + code, + & h1 + div, + & h1 + h1, + & h1 + h2, + & h1 + h3, + & h1 + h4, + & h1 + h5, + & h1 + h6 { + margin-top: 12px; + } + + & h2 + p, + & h2 + a, + & h2 + ul, + & h2 + ol, + & h2 + code, + & h2 + div, + & h2 + h1, + & h2 + h2, + & h2 + h3, + & h2 + h4, + & h2 + h5, + & h2 + h6 { + margin-top: 12px; + } + + & h3 + p, + & h3 + a, + & h3 + ul, + & h3 + ol, + & h3 + code, + & h3 + div, + & h3 + h1, + & h3 + h2, + & h3 + h3, + & h3 + h4, + & h3 + h5, + & h3 + h6 { + margin-top: 12px; + } + + & h4 + p, + & h4 + a, + & h4 + ul, + & h4 + ol, + & h4 + code, + & h4 + div, + & h4 + h1, + & h4 + h2, + & h4 + h3, + & h4 + h4, + & h4 + h5, + & h4 + h6 { + margin-top: 12px; + } + + & h5 + p, + & h5 + a, + & h5 + ul, + & h5 + ol, + & h5 + code, + & h5 + div, + & h5 + h1, + & h5 + h2, + & h5 + h3, + & h5 + h4, + & h5 + h5, + & h5 + h6 { + margin-top: 12px; + } + + & h6 + p, + & h6 + a, + & h6 + ul, + & h6 + ol, + & h6 + code, + & h6 + div, + & h6 + h1, + & h6 + h2, + & h6 + h3, + & h6 + h4, + & h6 + h5, + & h6 + h6 { + margin-top: 12px; + } + @media (--viewportMedium) { justify-content: center; } @@ -61,11 +155,19 @@ .darkTheme h3, .darkTheme h4, .darkTheme h5, -.darkTheme h6, +.darkTheme h6 { + color: var(--matterColorLight); + + &::selection { + background-color: cyan; + color: unset; + } +} + .darkTheme p, .darkTheme li, .darkTheme blockquote { - color: var(--matterColorLight); + color: rgba(255, 255, 255, 0.85); &::selection { background-color: cyan; diff --git a/src/styles/marketplaceDefaults.css b/src/styles/marketplaceDefaults.css index e98065fc0..107444eee 100644 --- a/src/styles/marketplaceDefaults.css +++ b/src/styles/marketplaceDefaults.css @@ -34,6 +34,7 @@ --bannedColorDark: var(--marketplaceColor); --matterColorDark: #000000; + --matterColorAlmostDark: #1d1d1f; --matterColor: #4a4a4a; --matterColorAnti: #b2b2b2; --matterColorNegative: #e7e7e7; From f65ebd83fdab3b6eb71d756209949af8849a2a2f Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 18:07:37 +0200 Subject: [PATCH 41/99] Changes by Vesa: add CSS comments and avoid useless calc() --- .../BlockDefault/BlockDefault.module.css | 2 +- .../Primitives/Heading/Heading.module.css | 4 ++++ .../Primitives/Ingress/Ingress.module.css | 1 + .../Primitives/List/List.module.css | 23 ++----------------- .../PageBuilder/Primitives/P/P.module.css | 1 + .../SectionArticle/SectionArticle.module.css | 3 ++- 6 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css index dc7ac303e..3dc8afb68 100644 --- a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css @@ -12,7 +12,7 @@ display: none; } - & + div { + & + .text { margin-top: 20px; } } diff --git a/src/containers/PageBuilder/Primitives/Heading/Heading.module.css b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css index fadd0fe0f..ef1894667 100644 --- a/src/containers/PageBuilder/Primitives/Heading/Heading.module.css +++ b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css @@ -17,6 +17,7 @@ font-size: 40px; /* If ´& + *´ if used, margin-top needs !important to overwrite */ + /* Handle margin-top of next adjacent element against this heading element */ & + p, & + a, & + ul, @@ -37,6 +38,7 @@ font-size: 30px; /* If ´& + *´ if used, margin-top needs !important to overwrite */ + /* Handle margin-top of next adjacent element against this heading element */ & + p, & + a, & + ul, @@ -57,6 +59,7 @@ font-size: 24px; /* If ´& + *´ if used, margin-top needs !important to overwrite */ + /* Handle margin-top of next adjacent element against this heading element */ & + p, & + a, & + ul, @@ -81,6 +84,7 @@ .h5, .h6 { /* If ´& + *´ if used, margin-top needs !important to overwrite */ + /* Handle margin-top of next adjacent element against this heading element */ & + p, & + a, & + ul, diff --git a/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css index 7593452ff..bf9131b4e 100644 --- a/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css +++ b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css @@ -5,6 +5,7 @@ margin-bottom: 0; letter-spacing: 0; + /* Handle margin-top of next adjacent element against this p element */ & + p, & + a, & + ul, diff --git a/src/containers/PageBuilder/Primitives/List/List.module.css b/src/containers/PageBuilder/Primitives/List/List.module.css index 359bbf6b2..b556b7acf 100644 --- a/src/containers/PageBuilder/Primitives/List/List.module.css +++ b/src/containers/PageBuilder/Primitives/List/List.module.css @@ -14,23 +14,9 @@ list-style-type: square; } } - - & + p, - & + a, - & + ul, - & + ol, - & + code, - & + div, - & + h1, - & + h2, - & + h3, - & + h4, - & + h5, - & + h6 { - margin-top: 24px; - } } - +/* Handle margin-top of next adjacent element against this list element */ +.ul, .ol { & + p, & + a, @@ -54,8 +40,3 @@ .li > p { margin: 0; } - -.ul + p, -.ol + p { - margin-top: 24px; -} diff --git a/src/containers/PageBuilder/Primitives/P/P.module.css b/src/containers/PageBuilder/Primitives/P/P.module.css index 4a4b84a9f..16f950201 100644 --- a/src/containers/PageBuilder/Primitives/P/P.module.css +++ b/src/containers/PageBuilder/Primitives/P/P.module.css @@ -5,6 +5,7 @@ margin-bottom: 0; letter-spacing: 0; + /* Handle margin-top of next adjacent element against this p element */ & + p, & + a, & + ul, diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css index 5f8c5db69..d67a45dbb 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css @@ -1,5 +1,6 @@ .articleMain { - max-width: calc(720px + (2 * 32px)); + /* 720px + (2 * 32px) == 784 */ + max-width: 784px; display: grid; grid-template-columns: repeat(1, 1fr); gap: 32px; From b9bbb684c6ddd35de23a45d445b5a6049a57090d Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 18:08:48 +0200 Subject: [PATCH 42/99] Changes by Vesa: use customBackground type in examples --- src/containers/PageBuilder/Markdown.example.js | 7 +++---- src/containers/PageBuilder/PageBuilder.example.js | 8 +++++--- .../SectionBuilder/SectionBuilder.example.js | 15 +++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index baf8124ed..0bb5deaca 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -131,6 +131,7 @@ const SectionLinks = { const horizontalRules = ` Some text + ___ divided by horizontal rule @@ -513,8 +514,7 @@ const SectionLinksOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-3-dark', numColumns: 2, - background: { type: 'customBackground', color: '#000000' }, - textColor: 'light', + background: { type: 'customBackground', color: '#000000', textColor: 'light' }, title: { type: 'heading2', content: 'Links on dark theme' }, blocks: [ { @@ -536,8 +536,7 @@ const SectionCodeOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - background: { type: 'customBackground', color: '#000000' }, - textColor: 'light', + background: { type: 'customBackground', color: '#000000', textColor: 'light' }, title: { type: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index ad63324dd..06aff6761 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -222,10 +222,12 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns2-section-3', numColumns: 3, - backgroundImage: { - type: 'image', + background: { + type: 'customBackground', + backgroundImage: imagePlaceholder(1200, 800, '#b6f7f9'), alt: 'Background image', - image: imagePlaceholder(1200, 800, '#b6f7f9'), + color: '#000000', + textColor: 'light', }, title: { type: 'heading2', content: '3 Columns' }, ingress: { diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index e0ccad2f8..bc59f26a8 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -384,8 +384,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-dark', numColumns: 1, - background: { type: 'customBackground', color: hexBlack }, - textColor: 'light', + background: { type: 'customBackground', color: hexBlack, textColor: 'light' }, title: { type: 'heading2', content: 'One Column, No Blocks' }, ingress: { type: 'paragraph', @@ -401,11 +400,12 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-bg-img', numColumns: 1, - background: { type: 'customBackground', color: hexYellow }, - backgroundImage: { - type: 'image', + background: { + type: 'customBackground', + backgroundImage: imagePlaceholder(400, 400), alt: 'Background image', - image: imagePlaceholder(400, 400), + color: '#000000', + textColor: 'light', }, title: { type: 'heading2', content: 'One Column, No Blocks, Bg Image' }, ingress: { @@ -492,8 +492,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-2-dark', numColumns: 2, - background: { type: 'customBackground', color: hexBlack }, - textColor: 'light', + background: { type: 'customBackground', color: hexBlack, textColor: 'light' }, title: { type: 'heading2', content: '2 Columns, Dark' }, ingress: { type: 'paragraph', From 172fd454349f49f8f0aec7037a0b0a727a030892 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 18:10:05 +0200 Subject: [PATCH 43/99] Changes by Vesa: handle title attribute of Link components --- src/containers/PageBuilder/Primitives/Link/Link.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/containers/PageBuilder/Primitives/Link/Link.js b/src/containers/PageBuilder/Primitives/Link/Link.js index e9e5561f3..afa1a7317 100644 --- a/src/containers/PageBuilder/Primitives/Link/Link.js +++ b/src/containers/PageBuilder/Primitives/Link/Link.js @@ -9,9 +9,10 @@ import { NamedLink, ExternalLink } from '../../../../components/index.js'; import css from './Link.module.css'; export const Link = React.forwardRef((props, ref) => { - const { className, rootClassName, href, children } = props; + const { className, rootClassName, href, title, children } = props; const classes = classNames(rootClassName || css.link, className); - const linkProps = { className: classes, href, children }; + const titleMaybe = title ? { title } : {}; + const linkProps = { className: classes, href, children, ...titleMaybe }; // Markdown parser (rehype-sanitize) might return undefined href if (!href || !children) { From 784ac9d47fbd1e8a8dd497c27ea0e81f5a785ec4 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 18:12:44 +0200 Subject: [PATCH 44/99] Changes by Vesa: add image field only if background is given --- .../SectionContainer/SectionContainer.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js index 241fb6952..284b3c089 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -14,16 +14,20 @@ const SectionContainer = props => { const classes = classNames(rootClassName || css.root, className); // Find background color if it is included - const colorProp = validProps(background, options); - const backgroundColorMaybe = colorProp?.color ? { backgroundColor: colorProp.color } : {}; + const backgroundProp = validProps(background, options); + const backgroundColorMaybe = backgroundProp?.color + ? { backgroundColor: backgroundProp.color } + : {}; return ( <Tag className={classes} id={id} style={backgroundColorMaybe} {...otherProps}> - <Field - data={{ ...background, alt: `Background image for ${id}` }} - className={className} - options={options} - /> + {backgroundProp ? ( + <Field + data={{ alt: `Background image for ${id}`, ...background }} + className={className} + options={options} + /> + ) : null} <div className={css.sectionContent}>{children}</div> </Tag> From 10fa546c0fa5cf7ba5de998d6006853ba88cb7c8 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 18:20:49 +0200 Subject: [PATCH 45/99] Changes by Vesa: customBackground should handle bg color --- .../Primitives/CustomBackground/CustomBackground.js | 6 ++++-- .../SectionBuilder/SectionArticle/SectionArticle.js | 2 -- .../SectionContainer/SectionContainer.js | 12 +++--------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js b/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js index b3340bfcb..30398dd42 100644 --- a/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js +++ b/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js @@ -8,16 +8,18 @@ import css from './CustomBackground.module.css'; // BackgroundImage doesn't have enforcable aspectratio export const CustomBackground = React.forwardRef((props, ref) => { - const { className, rootClassName, alt, backgroundImage, sizes } = props; + const { className, rootClassName, color, alt, backgroundImage, sizes } = props; const getVariantNames = img => { const { variants } = img?.attributes || {}; return variants ? Object.keys(variants) : []; }; + const backgroundColorMaybe = color ? { backgroundColor: color } : {}; + const classes = classNames(rootClassName || css.backgroundImageWrapper, className); return ( - <div className={classes}> + <div className={classes} style={backgroundColorMaybe}> {backgroundImage ? ( <ResponsiveImage className={css.backgroundImage} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index 0e9131db1..9818447c7 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -69,7 +69,6 @@ SectionArticle.defaultProps = { title: null, ingress: null, background: null, - backgroundImage: null, callToAction: null, blocks: [], isInsideContainer: false, @@ -89,7 +88,6 @@ SectionArticle.propTypes = { title: object, ingress: object, background: object, - backgroundImage: object, callToAction: object, blocks: arrayOf(object), isInsideContainer: bool, diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js index 284b3c089..4c55d87d1 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -2,7 +2,7 @@ import React from 'react'; import { func, node, object, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field, { validProps } from '../../Field'; +import Field from '../../Field'; import css from './SectionContainer.module.css'; @@ -13,15 +13,9 @@ const SectionContainer = props => { const Tag = as || 'section'; const classes = classNames(rootClassName || css.root, className); - // Find background color if it is included - const backgroundProp = validProps(background, options); - const backgroundColorMaybe = backgroundProp?.color - ? { backgroundColor: backgroundProp.color } - : {}; - return ( - <Tag className={classes} id={id} style={backgroundColorMaybe} {...otherProps}> - {backgroundProp ? ( + <Tag className={classes} id={id} {...otherProps}> + {background ? ( <Field data={{ alt: `Background image for ${id}`, ...background }} className={className} From 648d93610e52370d6de9309b46be2bfe15ba1e97 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 19:07:57 +0200 Subject: [PATCH 46/99] Changes by Vesa: SectionBuilder should not warn about custom sections --- .../SectionBuilder/SectionBuilder.js | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index 353d5f893..1001b1c67 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -102,14 +102,36 @@ const propTypeOption = shape({ isInsideContainer: bool, }); +const defaultSections = shape({ + sections: arrayOf(propTypeSection), + options: propTypeOption, +}); + +const customSection = shape({ + sectionId: string.isRequired, + sectionType: string.isRequired, + // Plus all kind of unknown fields. + // BlockBuilder doesn't really need to care about those +}); +const propTypeOptionForCustomSections = shape({ + fieldComponents: shape({ component: node, pickValidProps: func }), + blockComponents: shape({ component: node }), + sectionComponents: shape({ component: node }).isRequired, + // isInsideContainer boolean means that the section is not taking + // the full viewport width but is run inside some wrapper. + isInsideContainer: bool, +}); + +const customSections = shape({ + sections: arrayOf(customSection), + options: propTypeOptionForCustomSections.isRequired, +}); + SectionBuilder.defaultProps = { sections: [], options: null, }; -SectionBuilder.propTypes = { - sections: arrayOf(propTypeSection), - options: propTypeOption, -}; +SectionBuilder.propTypes = oneOf([defaultSections, customSections]).isRequired; export default SectionBuilder; From c565eaf7a03e5d1407db5b6fcadd4d762558f8d6 Mon Sep 17 00:00:00 2001 From: Janne Koivistoinen <janne@sharetribe.com> Date: Wed, 9 Nov 2022 04:24:17 +0200 Subject: [PATCH 47/99] add buttons to carousel template --- .../SectionCarousel/SectionCarousel.js | 38 +++++++++---- .../SectionCarousel.module.css | 54 +++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index 97689716b..c6eb5f357 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { arrayOf, func, node, number, object, shape, string } from 'prop-types'; import Field, { hasDataInFields } from '../../Field'; @@ -49,6 +49,16 @@ const SectionCarousel = props => { const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; + const slideLeft = () => { + var slider = document.getElementById('slider'); + slider.scrollLeft = slider.scrollLeft - 610; + }; + + const slideRight = () => { + var slider = document.getElementById('slider'); + slider.scrollLeft = slider.scrollLeft + 610; + }; + return ( <SectionContainer id={sectionId} @@ -65,14 +75,24 @@ const SectionCarousel = props => { </header> ) : null} {hasBlocks ? ( - <div className={getColumnCSS(numColumns)}> - <BlockBuilder - rootClassName={css.block} - ctaButtonClass={defaultClasses.ctaButton} - blocks={blocks} - responsiveImageSizes={getResponsiveImageSizes(numColumns)} - options={options} - /> + <div className={css.carouselContainer}> + <div className={css.carouselArrows}> + <button className={css.carouselArrowPrev} onClick={slideLeft}> + ‹ + </button> + <button className={css.carouselArrowNext} onClick={slideRight}> + › + </button> + </div> + <div className={getColumnCSS(numColumns)} id="slider"> + <BlockBuilder + rootClassName={css.block} + ctaButtonClass={defaultClasses.ctaButton} + blocks={blocks} + responsiveImageSizes={getResponsiveImageSizes(numColumns)} + options={options} + /> + </div> </div> ) : null} </SectionContainer> diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css index cb9dbc7f7..d59afc400 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -1,5 +1,58 @@ @import '../../../../styles/customMediaQueries.css'; +.carouselContainer { + display: flex; + align-items: center; + scroll-behavior: smooth; + + @media (--viewportMedium) { + &:hover .carouselArrows { + opacity: 1; + } + } +} + +.carouselArrows { + opacity: 0; + z-index: 2; + transition: all ease-in-out 500ms; +} + +.carouselArrowPrev, +.carouselArrowNext { + background-color: lightgrey; + width: 48px; + height: 48px; + border-radius: 100%; + color: black; + display: flex; + justify-content: center; + align-items: center; + font-size: 30px; + position: absolute; + z-index: 2; + border: none; + margin-top: -64px; + opacity: 0.9; + transition: all ease-in-out 100ms; + + &:hover { + opacity: 1; + cursor: pointer; + transition: all ease-in-out 100ms; + background: black; + color: white; + } +} + +.carouselArrowPrev { + left: 48px; +} + +.carouselArrowNext { + right: 48px; +} + .baseCarousel { display: flex; flex-wrap: nowrap; @@ -7,6 +60,7 @@ -ms-overflow-style: none; scrollbar-width: none; scroll-snap-type: x mandatory; + scroll-behavior: smooth; &::-webkit-scrollbar { display: none; From 6e955704bb7fca9c676f479cb53a35a1a16d1518 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 15 Nov 2022 14:57:41 +0200 Subject: [PATCH 48/99] Add another Carousel example --- .../SectionBuilder/SectionBuilder.example.js | 204 +++++++++++++++++- 1 file changed, 194 insertions(+), 10 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index bc59f26a8..54f0c86ce 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -179,7 +179,7 @@ export const SectionCarousel = { sections: [ { sectionType: 'carousel', - sectionId: 'cms-features-section-no-block', + sectionId: 'cms-features-section-carousel1', numColumns: 1, title: { type: 'heading2', @@ -202,7 +202,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '1 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -220,7 +220,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '2 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -238,7 +238,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '3 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -256,7 +256,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '4 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -274,7 +274,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '5 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -292,7 +292,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '6 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -310,7 +310,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '7 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -328,7 +328,7 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '8 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', @@ -346,7 +346,191 @@ export const SectionCarousel = { media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, title: { type: 'heading3', - content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', + content: '9 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + ], + }, + + { + sectionType: 'carousel', + sectionId: 'cms-features-section-carousel2', + numColumns: 3, + title: { + type: 'heading2', + content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', + }, + ingress: { + type: 'paragraph', + content: + 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + callToAction: { + type: 'externalButtonLink', + href: '#', + label: 'Justo Tortor Amet', + }, + blocks: [ + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-1', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '1 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-2', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '2 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-3', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '3 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-4', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '4 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-5', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '5 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-6', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '6 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-7', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '7 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-8', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '8 Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, + text: { + type: 'markdown', + content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, + }, + callToAction: { + type: 'internalButtonLink', + href: '#', + label: 'Ultricies Elit Sem', + }, + }, + { + blockType: 'defaultBlock', + blockId: 'cms-carousel2-block-9', + media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + title: { + type: 'heading3', + content: '9 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { type: 'markdown', From deb75a3123aa7687aede22a470198944aaab70ca Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 15 Nov 2022 14:58:07 +0200 Subject: [PATCH 49/99] Fix Carousel's width calculation --- .../SectionCarousel/SectionCarousel.js | 56 +++++++++++++++---- .../SectionCarousel.module.css | 21 +++---- src/styles/marketplaceDefaults.css | 9 ++- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index c6eb5f357..3e4a0eddf 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { arrayOf, func, node, number, object, shape, string } from 'prop-types'; import Field, { hasDataInFields } from '../../Field'; @@ -7,6 +7,9 @@ import BlockBuilder from '../../BlockBuilder'; import SectionContainer from '../SectionContainer'; import css from './SectionCarousel.module.css'; +const KEY_CODE_ARROW_LEFT = 37; +const KEY_CODE_ARROW_RIGHT = 39; + // The number of columns (numColumns) affects styling and responsive images const COLUMN_CONFIG = [ { css: css.oneColumn, responsiveImageSizes: '(max-width: 767px) 100vw, 1200px' }, @@ -40,6 +43,18 @@ const SectionCarousel = props => { blocks, options, } = props; + const sliderId = `${props.sectionId}-slider`; + + useEffect(() => { + const setCarouselWidth = () => { + const elem = window.document.getElementById(sliderId); + elem.style.setProperty('--carouselWidth', `${elem.clientWidth}px`); + }; + setCarouselWidth(); + + window.addEventListener('resize', setCarouselWidth); + return () => window.removeEventListener('resize', setCarouselWidth); + }, []); // If external mapping has been included for fields // E.g. { h1: { component: MyAwesomeHeader } } @@ -47,16 +62,35 @@ const SectionCarousel = props => { const fieldOptions = { fieldComponents }; const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); - const hasBlocks = blocks?.length > 0; + const numberOfBlocks = blocks?.length; + const hasBlocks = numberOfBlocks > 0; + + const onSlideLeft = e => { + var slider = window.document.getElementById(sliderId); + const slideWidth = numColumns * slider?.firstChild?.clientWidth; + slider.scrollLeft = slider.scrollLeft - slideWidth; + // Fix for Safari + e.target.focus(); + }; - const slideLeft = () => { - var slider = document.getElementById('slider'); - slider.scrollLeft = slider.scrollLeft - 610; + const onSlideRight = e => { + var slider = window.document.getElementById(sliderId); + const slideWidth = numColumns * slider?.firstChild?.clientWidth; + slider.scrollLeft = slider.scrollLeft + slideWidth; + // Fix for Safari + e.target.focus(); }; - const slideRight = () => { - var slider = document.getElementById('slider'); - slider.scrollLeft = slider.scrollLeft + 610; + const onKeyDown = e => { + if (e.keyCode === KEY_CODE_ARROW_LEFT) { + // Prevent changing cursor position in input + e.preventDefault(); + onSlideLeft(e); + } else if (e.keyCode === KEY_CODE_ARROW_RIGHT) { + // Prevent changing cursor position in input + e.preventDefault(); + onSlideRight(e); + } }; return ( @@ -77,14 +111,14 @@ const SectionCarousel = props => { {hasBlocks ? ( <div className={css.carouselContainer}> <div className={css.carouselArrows}> - <button className={css.carouselArrowPrev} onClick={slideLeft}> + <button className={css.carouselArrowPrev} onClick={onSlideLeft} onKeyDown={onKeyDown}> ‹ </button> - <button className={css.carouselArrowNext} onClick={slideRight}> + <button className={css.carouselArrowNext} onClick={onSlideRight} onKeyDown={onKeyDown}> › </button> </div> - <div className={getColumnCSS(numColumns)} id="slider"> + <div className={getColumnCSS(numColumns)} id={sliderId}> <BlockBuilder rootClassName={css.block} ctaButtonClass={defaultClasses.ctaButton} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css index d59afc400..52135813b 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -76,16 +76,17 @@ .block { flex: 0 0 auto; - width: calc(100vw - 64px); /* 64px = horizontal layout paddings */ + width: calc(var(--carouselWidth) - 64px); /* 64px = horizontal layout paddings */ margin-right: 16px; scroll-snap-align: center; - /* Offset the start of the carousel so it follows the global grid layout (1200 / 2 = 600px) */ - transform: translateX(calc(max(var(--contentMaxWidth), 100vw) / 2 - 600px)); + /* Offset the start of the carousel so it follows the global grid layout (e.g. (1400 - 1200) / 2 = 100px) */ + /* Removing this uses the full page width for the slider pane */ + transform: translateX(max(calc((var(--carouselWidth) - var(--contentMaxWidth)) / 2 + 32px), 0px)); &:last-of-type { padding-right: 32px; - width: calc(100vw - 32px); /* 32px (padding-right above) */ + width: calc(var(--carouselWidth) - 32px); /* 32px (padding-right above) */ } } @@ -99,36 +100,36 @@ .twoColumns .block { max-width: calc( - (var(--contentMaxWidth) - 64px - 18px) / 2 + (min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 18px) / 2 ); /* 64px (horizontal layout paddings) - 18px (gutter) / 2 (number of columns) */ &:last-of-type { max-width: calc( - (var(--contentMaxWidth) - 32px + 18px) / 2 + (min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 18px) / 2 ); /* 32px (padding-right above) + 18px (gutter) / 2 (number of columns) */ } } .threeColumns .block { max-width: calc( - (var(--contentMaxWidth) - 64px - 32px) / 3 + (min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 32px) / 3 ); /* 64px (horizontal layout paddings) - 32px (two gutters á 18px) / 3 (number of columns) */ &:last-of-type { max-width: calc( - (var(--contentMaxWidth) - 32px + 32px) / 3 + (min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 32px) / 3 ); /* 32px (padding-right above) + 32px (two gutters á 18px) / 3 (number of columns) */ } } .fourColumns .block { max-width: calc( - (var(--contentMaxWidth) - 64px - 54px) / 4 + (min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 54px) / 4 ); /* 64px (horizontal layout paddings) - 54px (three gutters á 18px) / 4 (number of columns) */ &:last-of-type { max-width: calc( - (var(--contentMaxWidth) - 32px + 54px) / 4 + (min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 54px) / 4 ); /* 32px (padding-right above) + 54px (three gutters á 18px) / 4 (number of columns) */ } } diff --git a/src/styles/marketplaceDefaults.css b/src/styles/marketplaceDefaults.css index 107444eee..a60804f9d 100644 --- a/src/styles/marketplaceDefaults.css +++ b/src/styles/marketplaceDefaults.css @@ -144,11 +144,6 @@ /* ================ TabNav ================ */ --TabNav_linkWidth: 240px; - /* ================ LandingPage ================ */ - --LandingPage_sectionMarginTop: 40px; - --LandingPage_sectionMarginTopMedium: 60px; - --LandingPage_sectionMarginTopLarge: 94px; - /* ================ EditListingAvailabilityForm, ManageAvailabilityCalendar ================ */ --ManageAvailabilityCalendar_gridColor: #e0e0e0; --ManageAvailabilityCalendar_availableColor: #ffffff; @@ -162,6 +157,10 @@ /* ================ ProfileSettingsForm ================ */ --ProfileSettingsForm_avatarSize: 96px; --ProfileSettingsForm_avatarSizeDesktop: 240px; + + /* ================ PageBuilder ================ */ + /* --carouselWidth will be updated dynamically through JS */ + --carouselWidth: 100vw; } /* ================ Global element styles ================ */ From 75719774db64ad439b4e01a7243862db344b3823 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 16 Nov 2022 18:06:19 +0200 Subject: [PATCH 50/99] Add new shared class .blockContainer --- .../SectionBuilder/SectionArticle/SectionArticle.js | 6 +++++- .../PageBuilder/SectionBuilder/SectionBuilder.js | 1 + .../PageBuilder/SectionBuilder/SectionBuilder.module.css | 8 ++++---- .../SectionBuilder/SectionColumns/SectionColumns.js | 2 +- .../SectionBuilder/SectionFeatures/SectionFeatures.js | 6 +++++- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index 9818447c7..ddc071148 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -49,7 +49,11 @@ const SectionArticle = props => { </header> ) : null} {hasBlocks ? ( - <div className={classNames(css.articleMain, { [css.noSidePaddings]: isInsideContainer })}> + <div + className={classNames(defaultClasses.blockContainer, css.articleMain, { + [css.noSidePaddings]: isInsideContainer, + })} + > <BlockBuilder blocks={blocks} options={options} /> </div> ) : null} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index 1001b1c67..e4cee3563 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -23,6 +23,7 @@ const DEFAULT_CLASSES = { title: css.title, ingress: css.ingress, ctaButton: css.ctaButton, + blockContainer: css.blockContainer, }; ///////////////////////////////////////////// diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css index da3fc89d4..8982742ae 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css @@ -9,10 +9,6 @@ padding: 0 32px; position: relative; - & + div { - padding: 64px 32px 0 32px; - } - & h1 + p, & h1 + a, & h1 + ul, @@ -108,6 +104,10 @@ } } +.blockContainer { + padding: 64px 32px 0 32px; +} + .align { text-align: left; justify-self: start; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js index 043889c73..1a791bdc6 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js @@ -67,7 +67,7 @@ const SectionColumns = props => { ) : null} {hasBlocks ? ( <div - className={classNames(getColumnCSS(numColumns), { + className={classNames(defaultClasses.blockContainer, getColumnCSS(numColumns), { [css.noSidePaddings]: isInsideContainer, })} > diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js index ab0872700..889f43c5a 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -53,7 +53,11 @@ const SectionFeatures = props => { </header> ) : null} {hasBlocks ? ( - <div className={classNames(css.featuresMain, { [css.noSidePaddings]: isInsideContainer })}> + <div + className={classNames(defaultClasses.blockContainer, css.featuresMain, { + [css.noSidePaddings]: isInsideContainer, + })} + > <BlockBuilder rootClassName={css.block} ctaButtonClass={defaultClasses.ctaButton} From 9c2377d590719f77746a92bc4f223fe1ba05ab87 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 16 Nov 2022 18:19:28 +0200 Subject: [PATCH 51/99] SectionCarousel: fix width calculation --- .../SectionCarousel/SectionCarousel.js | 16 +++- .../SectionCarousel.module.css | 85 ++++++++++++------- 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index 3e4a0eddf..c9b08f98a 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; import { arrayOf, func, node, number, object, shape, string } from 'prop-types'; +import classNames from 'classnames'; import Field, { hasDataInFields } from '../../Field'; import BlockBuilder from '../../BlockBuilder'; @@ -43,12 +44,15 @@ const SectionCarousel = props => { blocks, options, } = props; + const sliderContainerId = `${props.sectionId}-container`; const sliderId = `${props.sectionId}-slider`; useEffect(() => { const setCarouselWidth = () => { - const elem = window.document.getElementById(sliderId); - elem.style.setProperty('--carouselWidth', `${elem.clientWidth}px`); + const windowWidth = window.innerWidth; + const elem = window.document.getElementById(sliderContainerId); + const carouselWidth = elem.clientWidth > windowWidth ? windowWidth : elem.clientWidth; + elem.style.setProperty('--carouselWidth', `${carouselWidth}px`); }; setCarouselWidth(); @@ -109,8 +113,12 @@ const SectionCarousel = props => { </header> ) : null} {hasBlocks ? ( - <div className={css.carouselContainer}> - <div className={css.carouselArrows}> + <div className={css.carouselContainer} id={sliderContainerId}> + <div + className={classNames(css.carouselArrows, { + [css.notEnoughBlocks]: numberOfBlocks <= numColumns, + })} + > <button className={css.carouselArrowPrev} onClick={onSlideLeft} onKeyDown={onKeyDown}> ‹ </button> diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css index 52135813b..0b596c9ee 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -4,6 +4,7 @@ display: flex; align-items: center; scroll-behavior: smooth; + padding: 64px 0 0 0; @media (--viewportMedium) { &:hover .carouselArrows { @@ -52,10 +53,16 @@ .carouselArrowNext { right: 48px; } +.notEnoughBlocks { + @media (--viewportMedium) { + display: none; + } +} .baseCarousel { display: flex; flex-wrap: nowrap; + width: var(--carouselWidth); overflow-x: auto; -ms-overflow-style: none; scrollbar-width: none; @@ -76,60 +83,80 @@ .block { flex: 0 0 auto; - width: calc(var(--carouselWidth) - 64px); /* 64px = horizontal layout paddings */ + /* 64px = horizontal layout paddings */ + width: calc(min(var(--contentMaxWidth), var(--carouselWidth)) - 64px); + margin-right: 16px; scroll-snap-align: center; - /* Offset the start of the carousel so it follows the global grid layout (e.g. (1400 - 1200) / 2 = 100px) */ - /* Removing this uses the full page width for the slider pane */ - transform: translateX(max(calc((var(--carouselWidth) - var(--contentMaxWidth)) / 2 + 32px), 0px)); + transform: translateX(32px); &:last-of-type { padding-right: 32px; - width: calc(var(--carouselWidth) - 32px); /* 32px (padding-right above) */ + /* 32px (padding-right above) */ + width: calc(min(var(--contentMaxWidth), var(--carouselWidth)) - 32px); + } + + @media (min-width: 1200px) { + /* Offset the start of the carousel so it follows the global grid layout (e.g. (1400 - 1200) / 2 = 100px) */ + /* Removing this uses the full page width for the slider pane */ + transform: translateX( + max(calc((var(--carouselWidth) - var(--contentMaxWidth)) / 2 + 32px), 0px) + ); + + &:last-of-type { + padding-right: 32px; + width: calc( + min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + ); /* 32px (padding-right above) */ + } } } .oneColumn .block { - max-width: calc(var(--contentMaxWidth) - 64px); /* 64px (horizontal layout paddings) */ + @media (--viewportMedium) { + /* 64px (horizontal layout paddings) */ + width: calc(min(var(--contentMaxWidth), var(--carouselWidth)) - 64px); - &:last-of-type { - max-width: calc(var(--contentMaxWidth) - 32px); /* 32px (padding-right above) */ + &:last-of-type { + /* 32px (padding-right) */ + width: calc(min(var(--contentMaxWidth), var(--carouselWidth)) - 32px); + } } } .twoColumns .block { - max-width: calc( - (min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 18px) / 2 - ); /* 64px (horizontal layout paddings) - 18px (gutter) / 2 (number of columns) */ + @media (--viewportMedium) { + /* 64px (horizontal layout paddings) - 18px (gutter) / 2 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 18px) / 2); - &:last-of-type { - max-width: calc( - (min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 18px) / 2 - ); /* 32px (padding-right above) + 18px (gutter) / 2 (number of columns) */ + &:last-of-type { + /* 32px (padding-right above) + 18px (gutter) / 2 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 18px) / 2); + } } } .threeColumns .block { - max-width: calc( - (min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 32px) / 3 - ); /* 64px (horizontal layout paddings) - 32px (two gutters á 18px) / 3 (number of columns) */ + @media (--viewportMedium) { + /* 64px (horizontal layout paddings) - 32px (two gutters á 18px) / 3 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 32px) / 3); - &:last-of-type { - max-width: calc( - (min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 32px) / 3 - ); /* 32px (padding-right above) + 32px (two gutters á 18px) / 3 (number of columns) */ + &:last-of-type { + /* 32px (padding-right above) + 32px (two gutters á 18px) / 2 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 32px) / 3); + } } } .fourColumns .block { - max-width: calc( - (min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 54px) / 4 - ); /* 64px (horizontal layout paddings) - 54px (three gutters á 18px) / 4 (number of columns) */ + @media (--viewportMedium) { + /* 64px (horizontal layout paddings) - 54px (two gutters á 18px) / 3 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 54px) / 4); - &:last-of-type { - max-width: calc( - (min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 54px) / 4 - ); /* 32px (padding-right above) + 54px (three gutters á 18px) / 4 (number of columns) */ + &:last-of-type { + /* 32px (padding-right above) + 54px (two gutters á 18px) / 2 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 54px) / 4); + } } } From 5608497b755bd4f50db1bfb0a25a3f7655f7ac81 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 16 Nov 2022 18:20:14 +0200 Subject: [PATCH 52/99] SectionCarousel: refactor carousel arrow CSS-rules --- .../SectionCarousel/SectionCarousel.module.css | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css index 0b596c9ee..38953fc0f 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -21,20 +21,22 @@ .carouselArrowPrev, .carouselArrowNext { - background-color: lightgrey; - width: 48px; - height: 48px; - border-radius: 100%; - color: black; display: flex; justify-content: center; align-items: center; - font-size: 30px; position: absolute; z-index: 2; - border: none; + + width: 48px; + height: 48px; margin-top: -64px; + border-radius: 100%; + border: none; + + background-color: lightgrey; opacity: 0.9; + font-size: 30px; + color: black; transition: all ease-in-out 100ms; &:hover { From e64448794c97e6c9d7bd626a28fd6183c9843396 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 16 Nov 2022 18:20:59 +0200 Subject: [PATCH 53/99] Refactor asset name handling --- src/containers/AuthenticationPage/AuthenticationPage.js | 2 +- src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js | 7 ++----- src/containers/TermsOfServicePage/TermsOfServicePage.js | 3 ++- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/containers/AuthenticationPage/AuthenticationPage.js b/src/containers/AuthenticationPage/AuthenticationPage.js index 03556396f..abdbfde08 100644 --- a/src/containers/AuthenticationPage/AuthenticationPage.js +++ b/src/containers/AuthenticationPage/AuthenticationPage.js @@ -46,8 +46,8 @@ import { ConfirmSignupForm, LoginForm, SignupForm } from '../../forms'; import { TopbarContainer } from '../../containers'; import { - TermsOfServiceContent, TOS_ASSET_NAME, + TermsOfServiceContent, } from '../../containers/TermsOfServicePage/TermsOfServicePage'; import css from './AuthenticationPage.module.css'; diff --git a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js index 769c43c91..ed7d3f667 100644 --- a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js +++ b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js @@ -115,10 +115,7 @@ const PrivacyPolicyPage = compose( injectIntl )(PrivacyPolicyPageComponent); -export { - ASSET_NAME as PRIVACY_POLICY_ASSET_NAME, - PrivacyPolicyPageComponent, - PrivacyPolicyContent, -}; +const PRIVACY_POLICY_ASSET_NAME = ASSET_NAME; +export { PRIVACY_POLICY_ASSET_NAME, PrivacyPolicyPageComponent, PrivacyPolicyContent }; export default PrivacyPolicyPage; diff --git a/src/containers/TermsOfServicePage/TermsOfServicePage.js b/src/containers/TermsOfServicePage/TermsOfServicePage.js index 5d2a88218..b812243d7 100644 --- a/src/containers/TermsOfServicePage/TermsOfServicePage.js +++ b/src/containers/TermsOfServicePage/TermsOfServicePage.js @@ -115,6 +115,7 @@ const TermsOfServicePage = compose( injectIntl )(TermsOfServicePageComponent); -export { ASSET_NAME as TOS_ASSET_NAME, TermsOfServicePageComponent, TermsOfServiceContent }; +const TOS_ASSET_NAME = ASSET_NAME; +export { TOS_ASSET_NAME, TermsOfServicePageComponent, TermsOfServiceContent }; export default TermsOfServicePage; From 0f790073cfdf095f0528bae84594e43798ba33c1 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 16 Nov 2022 18:49:09 +0200 Subject: [PATCH 54/99] SectionCarousel: no need to show arrows for touch devices --- .../SectionCarousel/SectionCarousel.module.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css index 38953fc0f..60f1de3ee 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -6,9 +6,14 @@ scroll-behavior: smooth; padding: 64px 0 0 0; - @media (--viewportMedium) { + &:hover .carouselArrows { + opacity: 1; + } + + /* smartphones, touchscreens: we don't need to show arrows */ + @media (hover: none) and (pointer: coarse) { &:hover .carouselArrows { - opacity: 1; + opacity: 0; } } } From a85b2835df9359fc90199dcaad01458b3eee8b8d Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 14 Nov 2022 15:47:04 +0200 Subject: [PATCH 55/99] Lazy load Youtube iframe to improve page performance if iframe is below the fold. --- .../Primitives/YoutubeEmbed/YoutubeEmbed.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js index 6bd4200e1..0039c2e89 100644 --- a/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js +++ b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.js @@ -2,13 +2,19 @@ import React from 'react'; import { string } from 'prop-types'; import classNames from 'classnames'; +import { lazyLoadWithDimensions } from '../../../../util/contextHelpers'; + import { AspectRatioWrapper } from '../../../../components/index.js'; import css from './YoutubeEmbed.module.css'; const RADIX = 10; +const BLACK_BG = '#000000'; + +const IFrame = props => <iframe {...props} />; +const LazyIFrame = lazyLoadWithDimensions(IFrame); -export const YoutubeEmbed = React.forwardRef((props, ref) => { +export const YoutubeEmbed = props => { const { className, rootClassName, youtubeVideoId, aspectRatio } = props; const hasSlash = aspectRatio.indexOf('/') > 0; const [aspectWidth, aspectHeight] = hasSlash ? aspectRatio.split('/') : [16, 9]; @@ -18,9 +24,10 @@ export const YoutubeEmbed = React.forwardRef((props, ref) => { return ( <AspectRatioWrapper className={classes} width={width} height={height}> - <iframe + <LazyIFrame src={`https://www.youtube-nocookie.com/embed/${youtubeVideoId}`} className={css.iframe} + style={{ background: BLACK_BG }} frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowFullScreen @@ -28,7 +35,7 @@ export const YoutubeEmbed = React.forwardRef((props, ref) => { /> </AspectRatioWrapper> ); -}); +}; YoutubeEmbed.displayName = 'YoutubeEmbed'; From 15f94ad3fc58ea09a6d9e20af69ffbff12ec6459 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 15:26:31 +0200 Subject: [PATCH 56/99] SectionCarousel: fix error, when no blocks are given --- .../SectionCarousel/SectionCarousel.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index c9b08f98a..324213b29 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -46,13 +46,17 @@ const SectionCarousel = props => { } = props; const sliderContainerId = `${props.sectionId}-container`; const sliderId = `${props.sectionId}-slider`; + const numberOfBlocks = blocks?.length; + const hasBlocks = numberOfBlocks > 0; useEffect(() => { const setCarouselWidth = () => { - const windowWidth = window.innerWidth; - const elem = window.document.getElementById(sliderContainerId); - const carouselWidth = elem.clientWidth > windowWidth ? windowWidth : elem.clientWidth; - elem.style.setProperty('--carouselWidth', `${carouselWidth}px`); + if (hasBlocks) { + const windowWidth = window.innerWidth; + const elem = window.document.getElementById(sliderContainerId); + const carouselWidth = elem.clientWidth > windowWidth ? windowWidth : elem.clientWidth; + elem.style.setProperty('--carouselWidth', `${carouselWidth}px`); + } }; setCarouselWidth(); @@ -66,8 +70,6 @@ const SectionCarousel = props => { const fieldOptions = { fieldComponents }; const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); - const numberOfBlocks = blocks?.length; - const hasBlocks = numberOfBlocks > 0; const onSlideLeft = e => { var slider = window.document.getElementById(sliderId); From 52c53d5cc32cf3a2224e84dc2b0e6c1d5d4de08e Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 15:30:02 +0200 Subject: [PATCH 57/99] SectionBuilder: fix warning, when sectionId is not unique (operator error) --- src/containers/PageBuilder/SectionBuilder/SectionBuilder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index e4cee3563..9654125d5 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -59,7 +59,7 @@ const SectionBuilder = props => { return ( <> - {sections.map(section => { + {sections.map((section, index) => { const Section = getComponent(section.sectionType); // If the default "dark" theme should be applied // By default, this information is stored to customBackground field @@ -69,7 +69,7 @@ const SectionBuilder = props => { if (Section) { return ( <Section - key={section.sectionId} + key={`${section.sectionId}_${index}`} className={classes} defaultClasses={DEFAULT_CLASSES} isInsideContainer={isInsideContainer} From cbbd7eb757113c9a8cd10ee8ca80102b98aa1118 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 17:16:44 +0200 Subject: [PATCH 58/99] Field.helpers.js: refactor and improve link and image exposers --- src/containers/PageBuilder/Field/Field.helpers.js | 14 +++++++++----- .../PageBuilder/Field/Field.helpers.test.js | 8 +++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index c87d129a9..ab6a70054 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -33,11 +33,12 @@ export const exposeContentString = data => (hasContent(data) ? { content: data.c */ export const exposeLinkProps = data => { const { label, href } = data; - const hasCorrectProps = - typeof label === 'string' && typeof href === 'string' && label.length > 0 && href.length > 0; + const hasCorrectProps = typeof href === 'string' && href.length > 0; // Sanitize the URL. See: src/utl/sanitize.js for more information. const cleanUrl = hasCorrectProps ? sanitizeUrl(href) : null; - return cleanUrl ? { children: label, href: cleanUrl } : {}; + // If no label is given, use href. + const linkText = typeof label === 'string' && label.length > 0 ? label : cleanUrl; + return cleanUrl ? { children: linkText, href: cleanUrl } : {}; }; /** @@ -66,6 +67,8 @@ export const exposeLinkProps = data => { * @returns object containing alt string and variants. */ export const exposeImageProps = data => { + // Note: data includes also "aspectRatio" key, + // but image refs can rely on actual image variants const { alt, image } = data; const { id, type, attributes } = image || {}; @@ -87,9 +90,10 @@ export const exposeImageProps = data => { : validVariants; }, {}); - const isValidImage = typeof data?.alt === 'string' && Object.keys(variants).length > 0; + const alternativeText = typeof alt === 'string' ? alt : '🖼️'; + const isValidImage = Object.keys(variants).length > 0; const sanitizedImage = { id, type, attributes: { ...attributes, variants } }; - return isValidImage ? { alt, image: sanitizedImage } : {}; + return isValidImage ? { alt: alternativeText, image: sanitizedImage } : {}; }; /** diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index 785bfe708..d6eec16ff 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -53,9 +53,11 @@ describe('Field helpers', () => { }); it('should return empty object if data is not valid', () => { expect(exposeLinkProps({ label: 'Hello world!', blaa: 'blaa' })).toEqual({}); - expect(exposeLinkProps({ label: 0, href: 'https://my.example.com/some/image.png' })).toEqual( - {} - ); + }); + it('should return href as "children" if label is not valid', () => { + const href = 'https://my.example.com/some/image.png'; + expect(exposeLinkProps({ href })).toEqual({ children: href, href: href }); + expect(exposeLinkProps({ label: 0, href })).toEqual({ children: href, href: href }); }); it('should return "about:blank" in href if url in data is not valid', () => { expect( From 2e2c0b8d089776320390120acd25215e45800d23 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 17:18:10 +0200 Subject: [PATCH 59/99] SectionContainer: add custom background only if type is correct --- .../SectionBuilder/SectionContainer/SectionContainer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js index 4c55d87d1..41379e0df 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -15,7 +15,7 @@ const SectionContainer = props => { return ( <Tag className={classes} id={id} {...otherProps}> - {background ? ( + {background?.type === 'customBackground' ? ( <Field data={{ alt: `Background image for ${id}`, ...background }} className={className} From d74749358d67c6ca11e8c34926a06744015b78e0 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 17:36:01 +0200 Subject: [PATCH 60/99] Field: add propType shape for 'youtube' field --- src/containers/PageBuilder/Field/Field.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index 2ce63b2c0..dee0e700d 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -202,6 +202,12 @@ const propTypeCustomBackground = shape({ backgroundImage: propTypeImageAsset, }); +const propTypeYoutube = shape({ + type: oneOf(['youtube']).isRequired, + aspectRatio: string, + youtubeVideoId: string.isRequired, +}); + const propTypeOption = shape({ fieldComponents: shape({ component: node, pickValidProps: func }), }); @@ -222,6 +228,7 @@ Field.propTypes = { propTypeLink, propTypeImage, propTypeCustomBackground, + propTypeYoutube, propTypeEmptyObject, ]), options: propTypeOption, From a4406a42297db411b5eb8ce6761b614288f3e548 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 17:37:48 +0200 Subject: [PATCH 61/99] Field: 'label' for link is optional and so is 'alt' for image --- src/containers/PageBuilder/Field/Field.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index dee0e700d..c60905e1f 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -169,9 +169,10 @@ const propTypeTextContent = shape({ ]).isRequired, content: string.isRequired, }); + const propTypeLink = shape({ type: oneOf(['externalButtonLink', 'internalButtonLink']).isRequired, - label: string.isRequired, + label: string, href: string.isRequired, }); @@ -191,7 +192,7 @@ const propTypeImageAsset = shape({ const propTypeImage = shape({ type: oneOf(['image']).isRequired, - alt: string.isRequired, + alt: string, image: propTypeImageAsset.isRequired, }); From 0bb18ffbde02f3a97e5185be4d4e0604be08e72d Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 17:51:21 +0200 Subject: [PATCH 62/99] Field: remove the warning of different forms of empty fields that might get passed through asset file --- src/containers/PageBuilder/Field/Field.js | 55 ++++++++++++++++------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index c60905e1f..f3437e98d 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -25,6 +25,17 @@ import { exposeYoutubeProps, } from './Field.helpers'; +const TEXT_CONTENT = [ + 'heading1', + 'heading2', + 'heading3', + 'heading4', + 'heading5', + 'heading6', + 'paragraph', + 'markdown', +]; + //////////////////////// // Markdown component // //////////////////////// @@ -81,16 +92,29 @@ const defaultFieldComponents = { // Props picker // ////////////////// +const hasExactNumKeys = (obj, num) => Object.keys(obj).length === num; +const isEmptyObject = obj => hasExactNumKeys(obj, 0); +const hasOnlyProp = (obj, key) => hasExactNumKeys(obj, 1) && obj[key]; +const hasEmptyTextContent = obj => + hasExactNumKeys(obj, 2) && TEXT_CONTENT.includes(obj?.type) && obj?.content?.length === 0; + const getFieldConfig = (data, defaultFieldComponents, options) => { const customFieldComponents = options?.fieldComponents || {}; const fieldMapping = { ...defaultFieldComponents, ...customFieldComponents }; return fieldMapping[(data?.type)]; }; -// This is useful for fields that are not used as components (e.g. background-color) +// This is also useful for fields that are not used as components on their own +// E.g. if some field data is used as an attribute to HTML element. export const validProps = (data, options) => { - if (!data || Object.keys(data).length === 0) { - // If there's no data, the (optional) field in Console has been left untouched. + if ( + !data || + isEmptyObject(data) || + hasOnlyProp(data, 'type') || + hasEmptyTextContent(data) || + ['none'].includes(data?.type) + ) { + // If there's no data, the (optional) field in Console has been left untouched or it's removed. return null; } @@ -156,17 +180,7 @@ const Field = props => { // Field's prop types: const propTypeTextContent = shape({ - type: oneOf([ - 'heading1', - 'heading2', - 'heading3', - 'heading4', - 'heading5', - 'heading6', - 'ingress', - 'paragraph', - 'markdown', - ]).isRequired, + type: oneOf(TEXT_CONTENT).isRequired, content: string.isRequired, }); @@ -216,8 +230,16 @@ const propTypeOption = shape({ // Empty objects might be received through page data asset for optional fields. // If you get a warning "Failed prop type: Invalid prop `data` supplied to `Field`." // on localhost environment. -// This is the catch for those invalid data fields that don't have known "type". const propTypeEmptyObject = exact({}); +const propTypeTextEmptyObject = exact({ + type: oneOf(TEXT_CONTENT).isRequired, +}); +const propTypeDefaultBackground = shape({ + type: oneOf(['defaultBackground']).isRequired, +}); +const propTypeNone = shape({ + type: oneOf(['none']).isRequired, +}); Field.defaultProps = { options: null, @@ -231,6 +253,9 @@ Field.propTypes = { propTypeCustomBackground, propTypeYoutube, propTypeEmptyObject, + propTypeTextEmptyObject, + propTypeDefaultBackground, + propTypeNone, ]), options: propTypeOption, }; From e457f915ecde17b5939eac662b7e0d59c32fbed0 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 28 Dec 2022 18:04:08 +0200 Subject: [PATCH 63/99] SectionCarousel: use any-hover media query instead of hover --- .../SectionBuilder/SectionCarousel/SectionCarousel.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css index 60f1de3ee..10be4f9b4 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -11,7 +11,7 @@ } /* smartphones, touchscreens: we don't need to show arrows */ - @media (hover: none) and (pointer: coarse) { + @media (any-hover: none) and (pointer: coarse) { &:hover .carouselArrows { opacity: 0; } From 86e68d181bef4cb1b72b3a74d7ae47cc38bf4e03 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 4 Jan 2023 15:30:54 +0200 Subject: [PATCH 64/99] terms-of-service asset doesn't have 'page' postfix --- src/containers/TermsOfServicePage/TermsOfServicePage.duck.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/TermsOfServicePage/TermsOfServicePage.duck.js b/src/containers/TermsOfServicePage/TermsOfServicePage.duck.js index 3de1a6c9d..9f4524cfd 100644 --- a/src/containers/TermsOfServicePage/TermsOfServicePage.duck.js +++ b/src/containers/TermsOfServicePage/TermsOfServicePage.duck.js @@ -2,6 +2,6 @@ import { fetchPageAssets } from '../../ducks/hostedAssets.duck'; export const ASSET_NAME = 'terms-of-service'; export const loadData = (params, search) => dispatch => { - const pageAsset = { termsOfServicePage: `content/pages/${ASSET_NAME}.json` }; + const pageAsset = { termsOfService: `content/pages/${ASSET_NAME}.json` }; return dispatch(fetchPageAssets(pageAsset, true)); }; From a92e0d1f6d2cb34960528f16b6e575bafcfb6115 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 4 Jan 2023 15:31:58 +0200 Subject: [PATCH 65/99] privacy-policy asset doesn't have 'page' postfix --- src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js index 57ad02ffd..0cb6e98cd 100644 --- a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js +++ b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.duck.js @@ -2,6 +2,6 @@ import { fetchPageAssets } from '../../ducks/hostedAssets.duck'; export const ASSET_NAME = 'privacy-policy'; export const loadData = (params, search) => dispatch => { - const pageAsset = { privacyPolicyPage: `content/pages/${ASSET_NAME}.json` }; + const pageAsset = { privacyPolicy: `content/pages/${ASSET_NAME}.json` }; return dispatch(fetchPageAssets(pageAsset, true)); }; From 77af1ed50648cbf18b7d9462c9e38dc4dbf3fbdf Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 4 Jan 2023 15:32:33 +0200 Subject: [PATCH 66/99] Remove unused import of prop-types --- src/containers/PageBuilder/PageBuilder.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/containers/PageBuilder/PageBuilder.js b/src/containers/PageBuilder/PageBuilder.js index 1566a513c..69b1c8b1c 100644 --- a/src/containers/PageBuilder/PageBuilder.js +++ b/src/containers/PageBuilder/PageBuilder.js @@ -1,5 +1,4 @@ import React from 'react'; -import { arrayOf, func, node, oneOf, shape, string } from 'prop-types'; import { Footer as FooterContent } from '../../components/index.js'; import { TopbarContainer } from '../../containers/index.js'; From d26b80962c747d2a42f3463794933785c0692b81 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 4 Jan 2023 15:36:08 +0200 Subject: [PATCH 67/99] Remove unused css modules --- .../PageBuilder/Primitives/Image/Image.module.css | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/containers/PageBuilder/Primitives/Image/Image.module.css b/src/containers/PageBuilder/Primitives/Image/Image.module.css index 56a40ee01..6dee56d59 100644 --- a/src/containers/PageBuilder/Primitives/Image/Image.module.css +++ b/src/containers/PageBuilder/Primitives/Image/Image.module.css @@ -4,20 +4,6 @@ object-fit: cover; } -.backgroundImageWrapper { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; -} - -.backgroundImage { - object-fit: cover; - width: 100%; - height: 100%; -} - .fieldImage { width: 100%; height: 100%; From 771bdcc48229f0145d523563d107e0e59e6e38dc Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 4 Jan 2023 15:51:53 +0200 Subject: [PATCH 68/99] Remove margin-top from inline elements (a and code) --- src/containers/PageBuilder/Markdown.example.js | 4 ++-- .../Primitives/Ingress/Ingress.module.css | 3 +-- .../PageBuilder/Primitives/Link/Link.module.css | 15 --------------- .../PageBuilder/Primitives/P/P.module.css | 3 +-- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index 0bb5deaca..d41f93659 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -99,7 +99,7 @@ const SectionEmphasis = { }; const mdLinks = ` -Plain [link text](https://www.sharetribe.com/docs/) within a parapgraph. +Plain [link text](https://www.sharetribe.com/docs/) within a parapgraph. Another [link](https://www.sharetribe.com/docs/). [Link with title](https://www.sharetribe.com/docs/ "title text!") shows a title text, when mouse is hovering on top of it. @@ -411,7 +411,7 @@ const SectionImages = { }; const inlineCode = ` -Inline \`code\` +Inline \`code\` and \`another\` `; const codeBlockIndentation = ` diff --git a/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css index bf9131b4e..3adafe3e1 100644 --- a/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css +++ b/src/containers/PageBuilder/Primitives/Ingress/Ingress.module.css @@ -7,10 +7,9 @@ /* Handle margin-top of next adjacent element against this p element */ & + p, - & + a, & + ul, & + ol, - & + code, + & + pre, & + div, & + h1, & + h2, diff --git a/src/containers/PageBuilder/Primitives/Link/Link.module.css b/src/containers/PageBuilder/Primitives/Link/Link.module.css index 7864cea47..e05e31f95 100644 --- a/src/containers/PageBuilder/Primitives/Link/Link.module.css +++ b/src/containers/PageBuilder/Primitives/Link/Link.module.css @@ -1,19 +1,4 @@ .link { display: inline-block; color: var(--marketplaceColor); - - & + p, - & + a, - & + ul, - & + ol, - & + code, - & + div, - & + h1, - & + h2, - & + h3, - & + h4, - & + h5, - & + h6 { - margin-top: 24px; - } } diff --git a/src/containers/PageBuilder/Primitives/P/P.module.css b/src/containers/PageBuilder/Primitives/P/P.module.css index 16f950201..0a4b1864b 100644 --- a/src/containers/PageBuilder/Primitives/P/P.module.css +++ b/src/containers/PageBuilder/Primitives/P/P.module.css @@ -7,10 +7,9 @@ /* Handle margin-top of next adjacent element against this p element */ & + p, - & + a, & + ul, & + ol, - & + code, + & + pre, & + div, & + h1, & + h2, From b6b00db140e100e2c51be2384a078eb85f68f394 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Wed, 4 Jan 2023 15:52:37 +0200 Subject: [PATCH 69/99] Add margin-top to CTA button --- .../SectionBuilder/SectionArticle/SectionArticle.js | 2 +- .../SectionBuilder/SectionArticle/SectionArticle.module.css | 4 ++++ .../PageBuilder/SectionBuilder/SectionBuilder.module.css | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index ddc071148..3478eef88 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -54,7 +54,7 @@ const SectionArticle = props => { [css.noSidePaddings]: isInsideContainer, })} > - <BlockBuilder blocks={blocks} options={options} /> + <BlockBuilder blocks={blocks} ctaButtonClass={css.ctaButton} options={options} /> </div> ) : null} </SectionContainer> diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css index d67a45dbb..861bbb2a5 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css @@ -12,3 +12,7 @@ padding-left: 0; padding-right: 0; } + +.ctaButton { + margin-top: 24px; +} diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css index 8982742ae..5669f4344 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css @@ -139,6 +139,7 @@ text-decoration: none; box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%); font-weight: 500; + margin-top: 24px; &:hover { text-decoration: none; From b0b830b793d10b7530a48bf08015ab29bac6b0ae Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 5 Jan 2023 15:57:56 +0200 Subject: [PATCH 70/99] Refactor PreviewResolverPage --- src/containers/PreviewResolverPage/PreviewResolverPage.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/containers/PreviewResolverPage/PreviewResolverPage.js b/src/containers/PreviewResolverPage/PreviewResolverPage.js index 3f8879b77..fd83fcd67 100644 --- a/src/containers/PreviewResolverPage/PreviewResolverPage.js +++ b/src/containers/PreviewResolverPage/PreviewResolverPage.js @@ -24,6 +24,7 @@ const PreviewResolverPage = props => { const parsedQueryString = parse(search); const assetPath = parsedQueryString?.['asset-path'] || ''; const pageAssetName = getPageAssetName(assetPath); + const hasCMSPagePath = !!pageAssetName; const toTermsOfServicePage = <NamedRedirect name="TermsOfServicePage" />; const toPrivacyPolicyPage = <NamedRedirect name="PrivacyPolicyPage" />; @@ -36,7 +37,9 @@ const PreviewResolverPage = props => { ? toTermsOfServicePage : pageAssetName === 'privacy-policy' ? toPrivacyPolicyPage - : pageAssetName && pageAssetName !== 'landing-page' + : pageAssetName === 'landing-page' + ? toLandingPage + : hasCMSPagePath ? toCMSPage : toLandingPage; }; From 1d48040bca596df817748e8dd5a8513ac2c21e80 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 5 Jan 2023 16:59:15 +0200 Subject: [PATCH 71/99] CMSPage: allow pageId to be passed through extraProps in routeConfiguration --- src/containers/CMSPage/CMSPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/CMSPage/CMSPage.js b/src/containers/CMSPage/CMSPage.js index e39714af8..55236b6c7 100644 --- a/src/containers/CMSPage/CMSPage.js +++ b/src/containers/CMSPage/CMSPage.js @@ -9,7 +9,7 @@ import PageBuilder from '../../containers/PageBuilder/PageBuilder'; export const CMSPageComponent = props => { const { params, pageAssetsData, inProgress, error } = props; - const pageId = params.pageId; + const pageId = params.pageId || props.pageId; if (!inProgress && error?.status === 404) { return <NotFoundPage />; From f6fef6cb78c8e835a1a9cab2cd66801a490bad73 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 19 Jan 2023 20:10:09 +0200 Subject: [PATCH 72/99] PageBuilder: update Link component to support anchors. --- src/Routes.js | 28 +++++++++++++---- .../PageBuilder/Primitives/Link/Link.js | 30 +++++++++++++++++-- src/styles/marketplaceDefaults.css | 5 ++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/Routes.js b/src/Routes.js index 9d106bd8e..0901994f2 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -37,7 +37,7 @@ const callLoadData = props => { } }; -const setPageScrollPosition = location => { +const setPageScrollPosition = (location, delayed) => { if (!location.hash) { // No hash, scroll to top window.scroll({ @@ -60,12 +60,23 @@ const setPageScrollPosition = location => { block: 'start', behavior: 'smooth', }); + } else { + // A naive attempt to make a delayed call to scrollIntoView + // Note: 300 milliseconds might not be enough, but adding too much delay + // might affect user initiated scrolling. + delayed = window.setTimeout(() => { + const reTry = document.querySelector(location.hash); + reTry.scrollIntoView({ + block: 'start', + behavior: 'smooth', + }); + }, 300); } } }; -const handleLocationChanged = (dispatch, location) => { - setPageScrollPosition(location); +const handleLocationChanged = (dispatch, location, delayed) => { + setPageScrollPosition(location, delayed); const path = canonicalRoutePath(routeConfiguration(), location); dispatch(locationChanged(location, path)); }; @@ -80,9 +91,10 @@ const handleLocationChanged = (dispatch, location) => { */ class RouteComponentRenderer extends Component { componentDidMount() { + this.delayed = null; // Calling loadData on initial rendering (on client side). callLoadData(this.props); - handleLocationChanged(this.props.dispatch, this.props.location); + handleLocationChanged(this.props.dispatch, this.props.location, this.delayed); } componentDidUpdate(prevProps) { @@ -93,7 +105,13 @@ class RouteComponentRenderer extends Component { // This makes it possible to use loadData as default client side data loading technique. // However it is better to fetch data before location change to avoid "Loading data" state. callLoadData(this.props); - handleLocationChanged(this.props.dispatch, this.props.location); + handleLocationChanged(this.props.dispatch, this.props.location, this.delayed); + } + } + + componentWillUnmount() { + if (this.delayed) { + window.clearTimeout(this.resetTimeoutId); } } diff --git a/src/containers/PageBuilder/Primitives/Link/Link.js b/src/containers/PageBuilder/Primitives/Link/Link.js index afa1a7317..c2aa090e3 100644 --- a/src/containers/PageBuilder/Primitives/Link/Link.js +++ b/src/containers/PageBuilder/Primitives/Link/Link.js @@ -1,6 +1,7 @@ import React from 'react'; import { node, string } from 'prop-types'; import classNames from 'classnames'; +import { useLocation } from 'react-router-dom'; import routeConfiguration from '../../../../routeConfiguration.js'; import { matchPathname } from '../../../../util/routes.js'; @@ -9,6 +10,7 @@ import { NamedLink, ExternalLink } from '../../../../components/index.js'; import css from './Link.module.css'; export const Link = React.forwardRef((props, ref) => { + const location = useLocation(); const { className, rootClassName, href, title, children } = props; const classes = classNames(rootClassName || css.link, className); const titleMaybe = title ? { title } : {}; @@ -21,10 +23,10 @@ export const Link = React.forwardRef((props, ref) => { if (href.charAt(0) === '/') { // Internal link - const matchedRoutes = matchPathname(href, routeConfiguration()); + const testURL = new URL('http://my.marketplace.com' + href); + const matchedRoutes = matchPathname(testURL.pathname, routeConfiguration()); if (matchedRoutes.length > 0) { const found = matchedRoutes[0]; - const testURL = new URL('http://my.marketplace.com' + href); const to = { search: testURL.search, hash: testURL.hash }; return ( <NamedLink name={found.route.name} params={found.params} to={to} {...linkProps} ref={ref} /> @@ -32,6 +34,30 @@ export const Link = React.forwardRef((props, ref) => { } } + if (href.charAt(0) === '#') { + if (typeof window !== 'undefined') { + const hash = href; + let testURL = new URL( + `http://my.marketplace.com${location.pathname}${location.hash}${location.search}` + ); + testURL.hash = hash; + const matchedRoutes = matchPathname(testURL.pathname, routeConfiguration()); + if (matchedRoutes.length > 0) { + const found = matchedRoutes[0]; + const to = { search: testURL.search, hash: testURL.hash }; + return ( + <NamedLink + name={found.route.name} + params={found.params} + to={to} + {...linkProps} + ref={ref} + /> + ); + } + } + } + return <ExternalLink {...linkProps} ref={ref} />; }); diff --git a/src/styles/marketplaceDefaults.css b/src/styles/marketplaceDefaults.css index a60804f9d..848576755 100644 --- a/src/styles/marketplaceDefaults.css +++ b/src/styles/marketplaceDefaults.css @@ -365,6 +365,11 @@ html { color: var(--matterColor); padding: 0; margin: 0; + scroll-padding-top: calc(var(--topbarHeight) + 1px); + + @media (--viewportMedium) { + scroll-padding-top: calc(var(--topbarHeightDesktop) + 1px); + } } ul { From 1b32f39d6071262d0f66b64dfdfc35027a9b9c5e Mon Sep 17 00:00:00 2001 From: Janne Koivistoinen <janne@sharetribe.com> Date: Mon, 23 Jan 2023 21:30:19 +0200 Subject: [PATCH 73/99] fix padding issues for the first block and sync carousel-column horizontal whitespace --- .../BlockDefault/BlockDefault.module.css | 3 ++- .../Primitives/Heading/Heading.module.css | 18 +------------ .../YoutubeEmbed/YoutubeEmbed.module.css | 2 ++ .../SectionBuilder/SectionBuilder.module.css | 12 ++++++--- .../SectionCarousel.module.css | 26 +++++++++---------- 5 files changed, 26 insertions(+), 35 deletions(-) diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css index 3dc8afb68..324a5001b 100644 --- a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css @@ -12,7 +12,8 @@ display: none; } - & + .text { + /* If there's an image, add margin-top to the following text div */ + &:not(:empty) + .text { margin-top: 20px; } } diff --git a/src/containers/PageBuilder/Primitives/Heading/Heading.module.css b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css index ef1894667..89f737054 100644 --- a/src/containers/PageBuilder/Primitives/Heading/Heading.module.css +++ b/src/containers/PageBuilder/Primitives/Heading/Heading.module.css @@ -57,29 +57,13 @@ .h3 { font-size: 24px; - - /* If ´& + *´ if used, margin-top needs !important to overwrite */ - /* Handle margin-top of next adjacent element against this heading element */ - & + p, - & + a, - & + ul, - & + ol, - & + code, - & + div, - & + h1, - & + h2, - & + h3, - & + h4, - & + h5, - & + h6 { - margin-top: 8px; - } } .h4 { font-size: 21px; } +.h3, .h4, .h5, .h6 { diff --git a/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css index 57f9c93a6..c300493a1 100644 --- a/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css +++ b/src/containers/PageBuilder/Primitives/YoutubeEmbed/YoutubeEmbed.module.css @@ -1,5 +1,7 @@ .video { position: relative; + overflow: hidden; + border-radius: 8px; } .iframe { position: absolute; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css index 5669f4344..442b4a3ba 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css @@ -99,15 +99,19 @@ margin-top: 12px; } + &:empty + .blockContainer { + padding: 0 32px 0 32px; + } + + &:not(:empty) + .blockContainer { + padding: 64px 32px 0 32px; + } + @media (--viewportMedium) { justify-content: center; } } -.blockContainer { - padding: 64px 32px 0 32px; -} - .align { text-align: left; justify-self: start; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css index 10be4f9b4..d67165501 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.module.css @@ -93,7 +93,7 @@ /* 64px = horizontal layout paddings */ width: calc(min(var(--contentMaxWidth), var(--carouselWidth)) - 64px); - margin-right: 16px; + margin-right: 32px; scroll-snap-align: center; transform: translateX(32px); @@ -134,36 +134,36 @@ .twoColumns .block { @media (--viewportMedium) { - /* 64px (horizontal layout paddings) - 18px (gutter) / 2 (number of columns) */ - width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 18px) / 2); + /* 64px (horizontal layout paddings) - 32px (one gutter á 32px) / 2 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 32px) / 2); &:last-of-type { - /* 32px (padding-right above) + 18px (gutter) / 2 (number of columns) */ - width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 18px) / 2); + /* 32px (padding-right above) / 2 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px) / 2); } } } .threeColumns .block { @media (--viewportMedium) { - /* 64px (horizontal layout paddings) - 32px (two gutters á 18px) / 3 (number of columns) */ - width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 32px) / 3); + /* 64px (horizontal layout paddings) - 32px (two gutters á 32px) / 3 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 64px) / 3); &:last-of-type { - /* 32px (padding-right above) + 32px (two gutters á 18px) / 2 (number of columns) */ - width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 32px) / 3); + /* 32px (padding-right above) / 3 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px) / 3); } } } .fourColumns .block { @media (--viewportMedium) { - /* 64px (horizontal layout paddings) - 54px (two gutters á 18px) / 3 (number of columns) */ - width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 54px) / 4); + /* 64px (horizontal layout paddings) - 96px (three gutters á 32px) / 4 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 64px - 96px) / 4); &:last-of-type { - /* 32px (padding-right above) + 54px (two gutters á 18px) / 2 (number of columns) */ - width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px + 54px) / 4); + /* 32px (padding-right above) / 4 (number of columns) */ + width: calc((min(var(--contentMaxWidth), var(--carouselWidth)) - 32px) / 4); } } } From 12e2556fd4ea0ec0a92f4fa8acd68504110bdfd6 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 24 Jan 2023 15:12:38 +0200 Subject: [PATCH 74/99] SectionBuilder: remove complex not empty rules --- .../SectionBuilder/SectionBuilder.module.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css index 442b4a3ba..84e1e3d94 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css @@ -99,16 +99,16 @@ margin-top: 12px; } - &:empty + .blockContainer { - padding: 0 32px 0 32px; + @media (--viewportMedium) { + justify-content: center; } +} - &:not(:empty) + .blockContainer { - padding: 64px 32px 0 32px; - } +.blockContainer { + padding: 64px 32px 0 32px; - @media (--viewportMedium) { - justify-content: center; + &:first-child { + padding: 0 32px; } } From 47734c344cc7ff636d48b75b30fad816d3456549 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 24 Jan 2023 15:13:27 +0200 Subject: [PATCH 75/99] BlockDefault: don't render internal parts of a block if there's no data --- .../BlockBuilder/BlockDefault/BlockDefault.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js index 28e5e6ffd..63cd84de5 100644 --- a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.js @@ -2,14 +2,15 @@ import React from 'react'; import { func, node, object, shape, string } from 'prop-types'; import classNames from 'classnames'; -import Field from '../../Field'; +import Field, { hasDataInFields } from '../../Field'; import BlockContainer from '../BlockContainer'; import css from './BlockDefault.module.css'; const FieldMedia = props => { const { className, media, sizes, options } = props; - return media ? ( + const hasMediaField = hasDataInFields([media], options); + return hasMediaField ? ( <div className={classNames(className, css.media)}> <Field data={media} sizes={sizes} options={options} /> </div> @@ -32,6 +33,8 @@ const BlockDefault = props => { options, } = props; const classes = classNames(rootClassName || css.root, className); + const hasTextComponentFields = hasDataInFields([title, text, callToAction], options); + return ( <BlockContainer id={blockId} className={classes}> <FieldMedia @@ -40,11 +43,13 @@ const BlockDefault = props => { className={mediaClassName} options={options} /> - <div className={classNames(textClassName, css.text)}> - <Field data={title} options={options} /> - <Field data={text} options={options} /> - <Field data={callToAction} className={ctaButtonClass} options={options} /> - </div> + {hasTextComponentFields ? ( + <div className={classNames(textClassName, css.text)}> + <Field data={title} options={options} /> + <Field data={text} options={options} /> + <Field data={callToAction} className={ctaButtonClass} options={options} /> + </div> + ) : null} </BlockContainer> ); }; From 7ec42bf2d23c707806a0a0fe9d538de03ca192cf Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 24 Jan 2023 15:13:45 +0200 Subject: [PATCH 76/99] BlockBuilder: remove complex not empty rules --- .../BlockDefault/BlockDefault.module.css | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css index 324a5001b..41409affe 100644 --- a/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css +++ b/src/containers/PageBuilder/BlockBuilder/BlockDefault/BlockDefault.module.css @@ -6,23 +6,13 @@ background-color: var(--matterColorNegative); /* Loading state color for the images */ border-radius: 8px; margin-bottom: 0; - - /* Don't render the div if there's no image to render */ - &:empty { - display: none; - } - - /* If there's an image, add margin-top to the following text div */ - &:not(:empty) + .text { - margin-top: 20px; - } } .text { width: 100%; + margin-top: 20px; - /* Don't render the div if there's no image to render */ - &:empty { - display: none; + &:first-child { + margin-top: 0; } } From 581c8442ac811cccd48ef5adf992e156305e369f Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 24 Jan 2023 15:20:12 +0200 Subject: [PATCH 77/99] Update darkTheme trigger: textColor='white' --- src/containers/PageBuilder/SectionBuilder/SectionBuilder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index 9654125d5..c71378592 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -61,9 +61,9 @@ const SectionBuilder = props => { <> {sections.map((section, index) => { const Section = getComponent(section.sectionType); - // If the default "dark" theme should be applied + // If the default "dark" theme should be applied (when text color is white). // By default, this information is stored to customBackground field - const isDarkTheme = section?.background?.textColor === 'light'; + const isDarkTheme = section?.background?.textColor === 'white'; const classes = classNames({ [css.darkTheme]: isDarkTheme }); if (Section) { From 895aff1b27d970217a2aa2e5c083b31c694675a0 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 26 Jan 2023 15:34:59 +0200 Subject: [PATCH 78/99] Update picsum config --- server/csp.js | 1 + src/containers/PageBuilder/Markdown.example.js | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/csp.js b/server/csp.js index ae4acf7f4..fa5dfe6c7 100644 --- a/server/csp.js +++ b/server/csp.js @@ -51,6 +51,7 @@ const defaultDirectives = { // Styleguide placeholder images 'picsum.photos', + '*.picsum.photos', 'via.placeholder.com', 'api.mapbox.com', diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index d41f93659..284547bb2 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -350,11 +350,11 @@ const SectionBlockquotes = { }; const mdImage1 = ` -![Alt text](https://picsum.photos/400) +![Alt text](https://picsum.photos/400/400) `; const mdImage2 = ` -![Alt text](https://picsum.photos/400 "Title text") +![Alt text](https://picsum.photos/400/400 "Title text") `; const mdImageFootnoteStyle = ` @@ -362,7 +362,7 @@ const mdImageFootnoteStyle = ` Like links, Images also have a footnote style syntax with a reference later in the markdown content defining the URL location: -[id]: https://picsum.photos/400 "Title text" +[id]: https://picsum.photos/400/400 "Title text" `; const SectionImages = { From af002c6dfe67c4e44929cfd97eecd4b6de453d5c Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 26 Jan 2023 15:49:08 +0200 Subject: [PATCH 79/99] Update examples with textColor: 'white' --- src/containers/PageBuilder/Markdown.example.js | 4 ++-- src/containers/PageBuilder/PageBuilder.example.js | 3 +-- .../PageBuilder/SectionBuilder/SectionBuilder.example.js | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index 284547bb2..39e817428 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -514,7 +514,7 @@ const SectionLinksOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-3-dark', numColumns: 2, - background: { type: 'customBackground', color: '#000000', textColor: 'light' }, + background: { type: 'customBackground', color: '#000000', textColor: 'white' }, title: { type: 'heading2', content: 'Links on dark theme' }, blocks: [ { @@ -536,7 +536,7 @@ const SectionCodeOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - background: { type: 'customBackground', color: '#000000', textColor: 'light' }, + background: { type: 'customBackground', color: '#000000', textColor: 'white' }, title: { type: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index 06aff6761..1a84b7719 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -227,14 +227,13 @@ export const PageWithBuildInSectionColumns = { backgroundImage: imagePlaceholder(1200, 800, '#b6f7f9'), alt: 'Background image', color: '#000000', - textColor: 'light', + textColor: 'white', }, title: { type: 'heading2', content: '3 Columns' }, ingress: { type: 'heading2', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, - textColor: 'light', blocks: [ { blockType: 'defaultBlock', diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index 54f0c86ce..b0d41eec1 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -589,7 +589,7 @@ export const SectionColumns = { backgroundImage: imagePlaceholder(400, 400), alt: 'Background image', color: '#000000', - textColor: 'light', + textColor: 'white', }, title: { type: 'heading2', content: 'One Column, No Blocks, Bg Image' }, ingress: { From da3d832dfb5cac183fb8fd90c354bf150e5623ef Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 30 Jan 2023 14:52:42 +0200 Subject: [PATCH 80/99] Rename fieldKey.type as fieldKey.fieldType (fieldType is as specific concept as sectionType and blockType) --- .../PageBuilder/BlockBuilder/README.md | 6 +- .../PageBuilder/Field/Field.helpers.js | 16 +- src/containers/PageBuilder/Field/Field.js | 30 +- src/containers/PageBuilder/Field/README.md | 6 +- .../PageBuilder/Markdown.example.js | 209 ++++----- .../PageBuilder/PageBuilder.example.js | 108 +++-- .../PageBuilder/Primitives/README.md | 2 +- src/containers/PageBuilder/README.md | 8 +- .../PageBuilder/SectionBuilder/README.md | 4 +- .../SectionBuilder/SectionBuilder.example.js | 420 +++++++++++------- .../SectionContainer/SectionContainer.js | 2 +- .../PrivacyPolicyPage/FallbackPage.js | 6 +- .../TermsOfServicePage/FallbackPage.js | 6 +- 13 files changed, 469 insertions(+), 354 deletions(-) diff --git a/src/containers/PageBuilder/BlockBuilder/README.md b/src/containers/PageBuilder/BlockBuilder/README.md index 0907c6220..089871d9d 100644 --- a/src/containers/PageBuilder/BlockBuilder/README.md +++ b/src/containers/PageBuilder/BlockBuilder/README.md @@ -18,15 +18,15 @@ block type. blockType: 'defaultBlock', blockId: 'block-1', title: { - type: 'heading2', + fieldType: 'heading2', content: 'Hello world!', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** consectetur adepisci velit`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '/s', label: 'Go to search page', }, diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index ab6a70054..e0eb2b1d9 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -9,7 +9,7 @@ const hasContent = data => typeof data?.content === 'string' && data?.content.le /** * Exposes "content" prop as children property, if "content" has type of string. * - * @param {Object} data E.g. "{ type: 'heading3', content: 'my title' }" + * @param {Object} data E.g. "{ fieldType: 'heading3', content: 'my title' }" * @returns object containing content string as value for key: children. */ export const exposeContentAsChildren = data => { @@ -17,9 +17,9 @@ export const exposeContentAsChildren = data => { }; /** - * Exposes "content" property, if "content" has type of string. + * Exposes "content" property, if the type of the "content" is string. * - * @param {Object} data E.g. "{ type: 'markdown', content: 'my title' }" + * @param {Object} data E.g. "{ fieldType: 'markdown', content: 'my title' }" * @returns object containing "content" key if the value is string. */ export const exposeContentString = data => (hasContent(data) ? { content: data.content } : {}); @@ -28,7 +28,7 @@ export const exposeContentString = data => (hasContent(data) ? { content: data.c * Exposes "label" and "href" as "children" and "href" props respectively, * if both are of type string. Exposed "href" is sanitized. * - * @param {Object} data E.g. "{ type: 'link', label: 'my title', href: 'https://my.domain.com' }" + * @param {Object} data E.g. "{ fieldType: 'internalButtonLink', label: 'my title', href: 'https://my.domain.com' }" * @returns object containing children and href. */ export const exposeLinkProps = data => { @@ -63,11 +63,11 @@ export const exposeLinkProps = data => { * }, * } * - * @param {Object} data E.g. "{ type: 'image', alt: 'my portrait', image: { id, type, attributes } }" + * @param {Object} data E.g. "{ fieldType: 'image', alt: 'my portrait', image: { id, type, attributes } }" * @returns object containing alt string and variants. */ export const exposeImageProps = data => { - // Note: data includes also "aspectRatio" key, + // Note: data includes also "aspectRatio" key (and "fieldType"), // but image refs can rely on actual image variants const { alt, image } = data; const { id, type, attributes } = image || {}; @@ -113,7 +113,7 @@ const exposeColorValue = color => { * if backgroundImage contains imageAsset entity and * color contains hexadecimal string like "#FF0000" or "#F00". * - * @param {Object} data E.g. "{ type: 'customBackground', backgroundImage: imageAssetRef, color: '#000000', textColor: '#FFFFFF' }" + * @param {Object} data E.g. "{ fieldType: 'customBackground', backgroundImage: imageAssetRef, color: '#000000', textColor: '#FFFFFF' }" * @returns object containing valid data. */ export const exposeCustomBackgroundProps = data => { @@ -159,7 +159,7 @@ export const exposeCustomBackgroundProps = data => { * Exposes "youtubeVideoId" and "aspectRatio", * if they meet the regexp rules. * - * @param {Object} data E.g. "{ type: 'link', label: 'my title', href: 'https://my.domain.com' }" + * @param {Object} data E.g. "{ fieldType: 'youtube', youtubeVideoId: '<video-id>', aspectRatio: '16/9' }" * @returns object containing children and href. */ export const exposeYoutubeProps = data => { diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index f3437e98d..ecd5b101a 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -96,12 +96,12 @@ const hasExactNumKeys = (obj, num) => Object.keys(obj).length === num; const isEmptyObject = obj => hasExactNumKeys(obj, 0); const hasOnlyProp = (obj, key) => hasExactNumKeys(obj, 1) && obj[key]; const hasEmptyTextContent = obj => - hasExactNumKeys(obj, 2) && TEXT_CONTENT.includes(obj?.type) && obj?.content?.length === 0; + hasExactNumKeys(obj, 2) && TEXT_CONTENT.includes(obj?.fieldType) && obj?.content?.length === 0; const getFieldConfig = (data, defaultFieldComponents, options) => { const customFieldComponents = options?.fieldComponents || {}; const fieldMapping = { ...defaultFieldComponents, ...customFieldComponents }; - return fieldMapping[(data?.type)]; + return fieldMapping[(data?.fieldType)]; }; // This is also useful for fields that are not used as components on their own @@ -110,9 +110,9 @@ export const validProps = (data, options) => { if ( !data || isEmptyObject(data) || - hasOnlyProp(data, 'type') || + hasOnlyProp(data, 'fieldType') || hasEmptyTextContent(data) || - ['none'].includes(data?.type) + ['none'].includes(data?.fieldType) ) { // If there's no data, the (optional) field in Console has been left untouched or it's removed. return null; @@ -133,9 +133,9 @@ export const validProps = (data, options) => { if (data && !config) { // If there's no config, the field type is unknown => the app can't know what to render - console.warn(`Unknown field type (${data?.type}) detected. Data: ${JSON.stringify(data)}`); + console.warn(`Unknown field type (${data?.fieldType}) detected. Data: ${JSON.stringify(data)}`); } else if (data && !pickValidProps) { - console.warn(`There's no validator (pickValidProps) for this field type (${data?.type}).`); + console.warn(`There's no validator (pickValidProps) for this field type (${data?.fieldType}).`); } return null; }; @@ -157,7 +157,7 @@ export const hasDataInFields = (fields, fieldOptions) => { const isEmpty = obj => Object.keys(obj).length === 0; -// Generic field component that picks a specific UI component based on 'type' +// Generic field component that picks a specific UI component based on 'fieldType' const Field = props => { const { data, options: fieldOptions, ...propsFromParent } = props; @@ -180,12 +180,12 @@ const Field = props => { // Field's prop types: const propTypeTextContent = shape({ - type: oneOf(TEXT_CONTENT).isRequired, + fieldType: oneOf(TEXT_CONTENT).isRequired, content: string.isRequired, }); const propTypeLink = shape({ - type: oneOf(['externalButtonLink', 'internalButtonLink']).isRequired, + fieldType: oneOf(['externalButtonLink', 'internalButtonLink']).isRequired, label: string, href: string.isRequired, }); @@ -205,20 +205,20 @@ const propTypeImageAsset = shape({ }); const propTypeImage = shape({ - type: oneOf(['image']).isRequired, + fieldType: oneOf(['image']).isRequired, alt: string, image: propTypeImageAsset.isRequired, }); const propTypeCustomBackground = shape({ - type: oneOf(['customBackground']).isRequired, + fieldType: oneOf(['customBackground']).isRequired, color: string, textColor: string, backgroundImage: propTypeImageAsset, }); const propTypeYoutube = shape({ - type: oneOf(['youtube']).isRequired, + fieldType: oneOf(['youtube']).isRequired, aspectRatio: string, youtubeVideoId: string.isRequired, }); @@ -232,13 +232,13 @@ const propTypeOption = shape({ // on localhost environment. const propTypeEmptyObject = exact({}); const propTypeTextEmptyObject = exact({ - type: oneOf(TEXT_CONTENT).isRequired, + fieldType: oneOf(TEXT_CONTENT).isRequired, }); const propTypeDefaultBackground = shape({ - type: oneOf(['defaultBackground']).isRequired, + fieldType: oneOf(['defaultBackground']).isRequired, }); const propTypeNone = shape({ - type: oneOf(['none']).isRequired, + fieldType: oneOf(['none']).isRequired, }); Field.defaultProps = { diff --git a/src/containers/PageBuilder/Field/README.md b/src/containers/PageBuilder/Field/README.md index e155bc1b2..e6141f827 100644 --- a/src/containers/PageBuilder/Field/README.md +++ b/src/containers/PageBuilder/Field/README.md @@ -5,17 +5,17 @@ grouped under Section or Block content. For example, field data could look like ```json "title": { - "type": "heading1", + "fieldType": "heading1", "content": "Hello World" } ``` The Field component will check the type of the field and validate the data. If there are valid data -(e.g. if "content" is valid data for type "heading1"), the field renders the content. +(e.g. if "content" is valid data for the field type "heading1"), the field renders the content. ## Mapping of field types and components -The mapping of content type vs component & pickValidProps, could look like this: +The mapping of field type vs component & pickValidProps, could look like this: ```js const defaultFieldComponents = { diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index 39e817428..46fea0d9b 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -31,20 +31,23 @@ const SectionHeadings = { sectionType: 'columns', sectionId: 'cms-section-1', numColumns: 2, - title: { type: 'heading2', content: 'Headings' }, - ingress: { type: 'heading2', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...' }, + title: { fieldType: 'heading2', content: 'Headings' }, + ingress: { + fieldType: 'heading2', + content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', + }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-heading-block-1', - title: { type: 'heading3', content: 'Heading syntax' }, - text: { type: 'markdown', content: `\`\`\`${mdHeading}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Heading syntax' }, + text: { fieldType: 'markdown', content: `\`\`\`${mdHeading}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-heading-block-2', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: mdHeading }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: mdHeading }, }, ], }; @@ -69,31 +72,31 @@ const SectionEmphasis = { sectionType: 'columns', sectionId: 'cms-section-2', numColumns: 4, - title: { type: 'heading2', content: 'Emphasizing text' }, + title: { fieldType: 'heading2', content: 'Emphasizing text' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-emphasis-block-1', - title: { type: 'heading3', content: 'Bold' }, - text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisBold) }, + title: { fieldType: 'heading3', content: 'Bold' }, + text: { fieldType: 'markdown', content: addCodeBlockForSyntax(emphasisBold) }, }, { blockType: 'defaultBlock', blockId: 'cms-emphasis-block-2', - title: { type: 'heading3', content: 'Bold' }, - text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisBold2) }, + title: { fieldType: 'heading3', content: 'Bold' }, + text: { fieldType: 'markdown', content: addCodeBlockForSyntax(emphasisBold2) }, }, { blockType: 'defaultBlock', blockId: 'cms-emphasis-block-3', - title: { type: 'heading3', content: 'Italic' }, - text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisItalic) }, + title: { fieldType: 'heading3', content: 'Italic' }, + text: { fieldType: 'markdown', content: addCodeBlockForSyntax(emphasisItalic) }, }, { blockType: 'defaultBlock', blockId: 'cms-emphasis-block-4', - title: { type: 'heading3', content: 'Italic' }, - text: { type: 'markdown', content: addCodeBlockForSyntax(emphasisItalic2) }, + title: { fieldType: 'heading3', content: 'Italic' }, + text: { fieldType: 'markdown', content: addCodeBlockForSyntax(emphasisItalic2) }, }, ], }; @@ -112,19 +115,19 @@ const SectionLinks = { sectionType: 'columns', sectionId: 'cms-section-3', numColumns: 2, - title: { type: 'heading2', content: 'Links' }, + title: { fieldType: 'heading2', content: 'Links' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-link-block-1', - title: { type: 'heading3', content: 'Link syntax' }, - text: { type: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Link syntax' }, + text: { fieldType: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-link-block-2', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: mdLinks }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: mdLinks }, }, ], }; @@ -157,25 +160,25 @@ const SectionHorizontalRules = { sectionType: 'columns', sectionId: 'cms-section-4', numColumns: 3, - title: { type: 'heading2', content: 'Horizontal Rules' }, + title: { fieldType: 'heading2', content: 'Horizontal Rules' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-hr-block-1', - title: { type: 'heading3', content: 'With 3 underscore' }, - text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules) }, + title: { fieldType: 'heading3', content: 'With 3 underscore' }, + text: { fieldType: 'markdown', content: addCodeBlockForSyntax(horizontalRules) }, }, { blockType: 'defaultBlock', blockId: 'cms-hr-block-2', - title: { type: 'heading3', content: 'With 3 dash' }, - text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules2) }, + title: { fieldType: 'heading3', content: 'With 3 dash' }, + text: { fieldType: 'markdown', content: addCodeBlockForSyntax(horizontalRules2) }, }, { blockType: 'defaultBlock', blockId: 'cms-hr-block-3', - title: { type: 'heading3', content: 'With 3 asterisk' }, - text: { type: 'markdown', content: addCodeBlockForSyntax(horizontalRules3) }, + title: { fieldType: 'heading3', content: 'With 3 asterisk' }, + text: { fieldType: 'markdown', content: addCodeBlockForSyntax(horizontalRules3) }, }, ], }; @@ -223,55 +226,55 @@ const SectionLists = { sectionType: 'columns', sectionId: 'cms-section-5', numColumns: 2, - title: { type: 'heading2', content: 'Lists' }, + title: { fieldType: 'heading2', content: 'Lists' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-list-block-1', - title: { type: 'heading3', content: 'Unordered lists' }, - text: { type: 'markdown', content: `\`\`\`${unorderedList}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Unordered lists' }, + text: { fieldType: 'markdown', content: `\`\`\`${unorderedList}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-list-block-2', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: unorderedList }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: unorderedList }, }, { blockType: 'defaultBlock', blockId: 'cms-list-block-3', - title: { type: 'heading3', content: 'Ordered lists' }, - text: { type: 'markdown', content: `\`\`\`${orderedList}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Ordered lists' }, + text: { fieldType: 'markdown', content: `\`\`\`${orderedList}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-list-block-4', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: orderedList }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: orderedList }, }, { blockType: 'defaultBlock', blockId: 'cms-list-block-5', - title: { type: 'heading3', content: 'Keep all numbers as "1."' }, - text: { type: 'markdown', content: `\`\`\`${orderedList2}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Keep all numbers as "1."' }, + text: { fieldType: 'markdown', content: `\`\`\`${orderedList2}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-list-block-6', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: orderedList2 }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: orderedList2 }, }, { blockType: 'defaultBlock', blockId: 'cms-list-block-7', - title: { type: 'heading3', content: 'Start numbering with offset' }, - text: { type: 'markdown', content: `\`\`\`${orderedList3}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Start numbering with offset' }, + text: { fieldType: 'markdown', content: `\`\`\`${orderedList3}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-list-block-8', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: orderedList3 }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: orderedList3 }, }, ], }; @@ -308,43 +311,43 @@ const SectionBlockquotes = { sectionType: 'columns', sectionId: 'cms-section-6', numColumns: 2, - title: { type: 'heading2', content: 'Blockquotes' }, + title: { fieldType: 'heading2', content: 'Blockquotes' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-blockquote-block-1', - title: { type: 'heading3', content: 'Nested blockquotes' }, - text: { type: 'markdown', content: `\`\`\`${blockquotesNested}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Nested blockquotes' }, + text: { fieldType: 'markdown', content: `\`\`\`${blockquotesNested}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-blockquote-block-2', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: blockquotesNested }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: blockquotesNested }, }, { blockType: 'defaultBlock', blockId: 'cms-blockquote-block-3', - title: { type: 'heading3', content: 'Lazy arrow:' }, - text: { type: 'markdown', content: `\`\`\`${blockquotesLazyArray}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Lazy arrow:' }, + text: { fieldType: 'markdown', content: `\`\`\`${blockquotesLazyArray}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-blockquote-block-4', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: blockquotesLazyArray }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: blockquotesLazyArray }, }, { blockType: 'defaultBlock', blockId: 'cms-blockquote-block-5', - title: { type: 'heading3', content: 'Complex blockquotes' }, - text: { type: 'markdown', content: `\`\`\`${blockquotesComplex}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Complex blockquotes' }, + text: { fieldType: 'markdown', content: `\`\`\`${blockquotesComplex}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-blockquote-block-6', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: blockquotesComplex }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: blockquotesComplex }, }, ], }; @@ -369,43 +372,43 @@ const SectionImages = { sectionType: 'columns', sectionId: 'cms-section-7', numColumns: 2, - title: { type: 'heading2', content: 'Images' }, + title: { fieldType: 'heading2', content: 'Images' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-image-block-1', - title: { type: 'heading3', content: 'With "alt" for screenreaders' }, - text: { type: 'markdown', content: `\`\`\`${mdImage1}\`\`\`` }, + title: { fieldType: 'heading3', content: 'With "alt" for screenreaders' }, + text: { fieldType: 'markdown', content: `\`\`\`${mdImage1}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-image-block-2', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: mdImage1 }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: mdImage1 }, }, { blockType: 'defaultBlock', blockId: 'cms-image-block-3', - title: { type: 'heading3', content: 'With "alt" and "title"' }, - text: { type: 'markdown', content: `\`\`\`${mdImage2}\`\`\`` }, + title: { fieldType: 'heading3', content: 'With "alt" and "title"' }, + text: { fieldType: 'markdown', content: `\`\`\`${mdImage2}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-image-block-4', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: mdImage2 }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: mdImage2 }, }, { blockType: 'defaultBlock', blockId: 'cms-image-block-5', - title: { type: 'heading3', content: 'Footnote style' }, - text: { type: 'markdown', content: `\`\`\`${mdImageFootnoteStyle}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Footnote style' }, + text: { fieldType: 'markdown', content: `\`\`\`${mdImageFootnoteStyle}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-image-block-6', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: mdImageFootnoteStyle }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: mdImageFootnoteStyle }, }, ], }; @@ -437,43 +440,43 @@ const SectionCode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - title: { type: 'heading2', content: 'Inline code and Code blocks' }, + title: { fieldType: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-code-block-1', - title: { type: 'heading3', content: 'Inline code uses backticks' }, - text: { type: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Inline code uses backticks' }, + text: { fieldType: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-2', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: inlineCode }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: inlineCode }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-3', - title: { type: 'heading3', content: 'Code block with indentation (4 spaces)' }, - text: { type: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Code block with indentation (4 spaces)' }, + text: { fieldType: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-4', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: codeBlockIndentation }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: codeBlockIndentation }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-5', - title: { type: 'heading3', content: 'Code block with backtick "fences" (```)' }, - text: { type: 'markdown', content: codeBlockFencesSyntax }, + title: { fieldType: 'heading3', content: 'Code block with backtick "fences" (```)' }, + text: { fieldType: 'markdown', content: codeBlockFencesSyntax }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-6', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: codeBlockFences }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: codeBlockFences }, }, ], }; @@ -514,20 +517,20 @@ const SectionLinksOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-3-dark', numColumns: 2, - background: { type: 'customBackground', color: '#000000', textColor: 'white' }, - title: { type: 'heading2', content: 'Links on dark theme' }, + background: { fieldType: 'customBackground', color: '#000000', textColor: 'white' }, + title: { fieldType: 'heading2', content: 'Links on dark theme' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-link-block-1-dark', - title: { type: 'heading3', content: 'Link syntax' }, - text: { type: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Link syntax' }, + text: { fieldType: 'markdown', content: `\`\`\`${mdLinks}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-link-block-2-dark', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: mdLinks }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: mdLinks }, }, ], }; @@ -536,44 +539,44 @@ const SectionCodeOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - background: { type: 'customBackground', color: '#000000', textColor: 'white' }, - title: { type: 'heading2', content: 'Inline code and Code blocks' }, + background: { fieldType: 'customBackground', color: '#000000', textColor: 'white' }, + title: { fieldType: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-code-block-1', - title: { type: 'heading3', content: 'Inline code uses backticks' }, - text: { type: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Inline code uses backticks' }, + text: { fieldType: 'markdown', content: `\`\`\`${inlineCode}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-2', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: inlineCode }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: inlineCode }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-3', - title: { type: 'heading3', content: 'Code block with indentation (4 spaces)' }, - text: { type: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, + title: { fieldType: 'heading3', content: 'Code block with indentation (4 spaces)' }, + text: { fieldType: 'markdown', content: `\`\`\`${codeBlockIndentation}\`\`\`` }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-4', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: codeBlockIndentation }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: codeBlockIndentation }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-5', - title: { type: 'heading3', content: 'Code block with backtick "fences" (```)' }, - text: { type: 'markdown', content: codeBlockFencesSyntax }, + title: { fieldType: 'heading3', content: 'Code block with backtick "fences" (```)' }, + text: { fieldType: 'markdown', content: codeBlockFencesSyntax }, }, { blockType: 'defaultBlock', blockId: 'cms-code-block-6', - title: { type: 'heading3', content: '...rendered' }, - text: { type: 'markdown', content: codeBlockFences }, + title: { fieldType: 'heading3', content: '...rendered' }, + text: { fieldType: 'markdown', content: codeBlockFences }, }, ], }; diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index 1a84b7719..e23e40f69 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -146,18 +146,18 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns-section-0', numColumns: 1, - title: { type: 'heading2', content: 'One Column' }, + title: { fieldType: 'heading2', content: 'One Column' }, ingress: { - type: 'heading2', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'page-builder-columns-section-0-block-1', - title: { type: 'heading3', content: 'Column 1' }, + title: { fieldType: 'heading3', content: 'Column 1' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet, consectetur adipiscing elit. Nulla orci nisi, lobortis sit amet posuere et, vulputate sit amet neque. Nam a est id lectus viverra sagittis. Proin sed imperdiet lorem. Duis aliquam fermentum purus, tincidunt venenatis felis gravida in. Sed imperdiet mi vitae consequat rhoncus. Sed velit leo, porta at lorem ac, iaculis fermentum leo. Morbi tellus orci, bibendum id ante vel, hendrerit efficitur lectus. Proin vitae condimentum justo. Phasellus finibus nisi quis neque feugiat, ac auctor ipsum suscipit.`, }, }, @@ -167,28 +167,28 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns-section-1', numColumns: 2, - background: { type: 'customBackground', color: hexYellow }, - title: { type: 'heading2', content: '2 Columns' }, + background: { fieldType: 'customBackground', color: hexYellow }, + title: { fieldType: 'heading2', content: '2 Columns' }, ingress: { - type: 'heading2', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'page-builder-columns-section-1-block-1', - title: { type: 'heading3', content: 'Column 1' }, + title: { fieldType: 'heading3', content: 'Column 1' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, }, { blockType: 'defaultBlock', blockId: 'page-builder-columns-section-1-block-2', - title: { type: 'heading3', content: 'Column 2' }, + title: { fieldType: 'heading3', content: 'Column 2' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, }, @@ -198,23 +198,31 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns-section-2', numColumns: 2, - title: { type: 'heading2', content: '2 Columns' }, + title: { fieldType: 'heading2', content: '2 Columns' }, ingress: { - type: 'heading2', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'page-builder-columns-section-2-block-1', - title: { type: 'heading3', content: 'Column 1' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 1' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, { blockType: 'defaultBlock', blockId: 'page-builder-columns-section-2-block-2', - title: { type: 'heading3', content: 'Column 2' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 2' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, ], }, @@ -223,35 +231,47 @@ export const PageWithBuildInSectionColumns = { sectionId: 'page-builder-columns2-section-3', numColumns: 3, background: { - type: 'customBackground', + fieldType: 'customBackground', backgroundImage: imagePlaceholder(1200, 800, '#b6f7f9'), alt: 'Background image', color: '#000000', textColor: 'white', }, - title: { type: 'heading2', content: '3 Columns' }, + title: { fieldType: 'heading2', content: '3 Columns' }, ingress: { - type: 'heading2', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-3-block-1', - title: { type: 'heading3', content: 'Column 1' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 1' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, { blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-3-block-2', - title: { type: 'heading3', content: 'Column 2' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 2' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, { blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-3-block-3', - title: { type: 'heading3', content: 'Column 3' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 3' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, ], }, @@ -259,35 +279,51 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns2-section-4', numColumns: 4, - title: { type: 'heading2', content: '4 Columns' }, + title: { fieldType: 'heading2', content: '4 Columns' }, ingress: { - type: 'heading2', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-1', - title: { type: 'heading3', content: 'Column 1' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 1' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, { blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-2', - title: { type: 'heading3', content: 'Column 2' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 2' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, { blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-3', - title: { type: 'heading3', content: 'Column 3' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 3' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, { blockType: 'defaultBlock', blockId: 'page-builder-columns2-section-4-block-4', - title: { type: 'heading3', content: 'Column 4' }, - media: { type: 'image', alt: 'Background image', image: imagePlaceholder(1200, 800) }, + title: { fieldType: 'heading3', content: 'Column 4' }, + media: { + fieldType: 'image', + alt: 'Background image', + image: imagePlaceholder(1200, 800), + }, }, ], }, diff --git a/src/containers/PageBuilder/Primitives/README.md b/src/containers/PageBuilder/Primitives/README.md index 3f9bdd145..a1a9f0ccf 100644 --- a/src/containers/PageBuilder/Primitives/README.md +++ b/src/containers/PageBuilder/Primitives/README.md @@ -6,7 +6,7 @@ example, field data could look like this: ```json "title": { - "type": "heading1", + "fieldType": "heading1", "content": "Hello World" } ``` diff --git a/src/containers/PageBuilder/README.md b/src/containers/PageBuilder/README.md index 056b629ec..23d3e09f1 100644 --- a/src/containers/PageBuilder/README.md +++ b/src/containers/PageBuilder/README.md @@ -8,12 +8,12 @@ solution with headless CMS services, the schema of the page asset represents the modeling**. It defines what kind of data needs to be asked from a content writer. In Flex, content writing happens in Console, which means that content writers are marketplace operators. -The smallest piece of information in page asset is a field. It defines a piece of data and its type. +The smallest piece of information in page asset is a field. It defines a piece of data and its fieldType. For example: ```json "title": { - "type": "heading1", + "fieldType": "heading1", "content": "Hello World" } ``` @@ -28,7 +28,7 @@ The default asset schema for page content has 3 levels that can include content **SectionBuilder** to render its content. Similarly, SectionBuilder passes _blocks_ array to **BlockBuilder**. All the fields are passed to the **Field** component, which validates and sanitizes the data and uses **Primitive** components to actually render them. **MarkdownProcessor** -is used to render fields with `"type": "markdown"`. +is used to render fields with `"fieldType": "markdown"`. Then **LayoutComposer** creates the layout areas for **Topbar**, **Footer**, and for the main content, which is created using a page asset. @@ -67,7 +67,7 @@ don't get content through the Asset Delivery API. { sectionType: 'customSection', sectionId: 'my-ection', - foo: { type: 'myField', bar: 'bar' }, + foo: { fieldType: 'myField', bar: 'bar' }, blocks: [ { blockType: 'customSectionBlock', diff --git a/src/containers/PageBuilder/SectionBuilder/README.md b/src/containers/PageBuilder/SectionBuilder/README.md index a7905e9d1..db885969a 100644 --- a/src/containers/PageBuilder/SectionBuilder/README.md +++ b/src/containers/PageBuilder/SectionBuilder/README.md @@ -16,7 +16,7 @@ SectionBuilder uses internal component **SectionArticle** to render the section sectionType: 'article', sectionId: 'my-article-section', title: { - type: 'heading1', + fieldType: 'heading1', content: 'Hello World!', }, blocks: [ @@ -24,7 +24,7 @@ SectionBuilder uses internal component **SectionArticle** to render the section blockType: 'defaultBlock', blockId: 'cms-article-section-block-1', text: { - type: 'markdown', + fieldType: 'markdown', content: 'My article content. _Lorem ipsum_ consectetur adepisci velit', }, }, diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index b0d41eec1..b2097f1be 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -35,16 +35,16 @@ export const SectionArticle = { sectionType: 'article', sectionId: 'cms-article-section', title: { - type: 'heading1', + fieldType: 'heading1', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Maecenas sed diam eget risus varius blandit sit amet non magna. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, callToAction: { - type: 'externalButtonLink', + fieldType: 'externalButtonLink', href: '#', label: 'Justo Tortor Amet', }, @@ -52,14 +52,18 @@ export const SectionArticle = { { blockType: 'defaultBlock', blockId: 'cms-article-section-block-1', - media: { type: 'image', alt: 'Cute dog smiling', image: imagePlaceholder(600, 800) }, + media: { + fieldType: 'image', + alt: 'Cute dog smiling', + image: imagePlaceholder(600, 800), + }, title: { - type: 'heading2', + fieldType: 'heading2', content: 'Vestibulum id ligula porta felis euismod semper. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec ullamcorper nulla non metus auctor fringilla. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Etiam porta sem malesuada magna mollis euismod. Maecenas sed diam eget risus varius blandit sit amet non magna. Maecenas faucibus mollis interdum. Sed posuere consectetur est at lobortis. Etiam porta sem malesuada magna mollis euismod. Etiam porta sem malesuada magna mollis euismod. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Maecenas faucibus mollis interdum. @@ -78,7 +82,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id ligula po Vestibulum id ligula porta felis euismod semper. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Donec id elit non mi porta gravida at eget metus. Nullam id dolor id nibh ultricies vehicula ut id elit.`, }, callToAction: { - type: 'externalButtonLink', + fieldType: 'externalButtonLink', href: 'https://www.sharetribe.com/academy/marketplace-funding/', label: 'Read the article', }, @@ -98,16 +102,16 @@ export const SectionFeatures = { sectionType: 'features', sectionId: 'cms-features-section-no-block', title: { - type: 'heading1', + fieldType: 'heading1', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', }, callToAction: { - type: 'externalButtonLink', + fieldType: 'externalButtonLink', href: '#', label: 'Justo Tortor Amet', }, @@ -115,17 +119,17 @@ export const SectionFeatures = { { blockType: 'defaultBlock', blockId: 'cms-features-block-1', - media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + media: { fieldType: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { - type: 'heading2', + fieldType: 'heading2', content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -133,17 +137,17 @@ export const SectionFeatures = { { blockType: 'defaultBlock', blockId: 'cms-features-block-2', - media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + media: { fieldType: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { - type: 'heading2', + fieldType: 'heading2', content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -151,17 +155,17 @@ export const SectionFeatures = { { blockType: 'defaultBlock', blockId: 'cms-features-block-3', - media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + media: { fieldType: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, title: { - type: 'heading2', + fieldType: 'heading2', content: 'Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -182,16 +186,16 @@ export const SectionCarousel = { sectionId: 'cms-features-section-carousel1', numColumns: 1, title: { - type: 'heading2', + fieldType: 'heading2', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', }, callToAction: { - type: 'externalButtonLink', + fieldType: 'externalButtonLink', href: '#', label: 'Justo Tortor Amet', }, @@ -199,17 +203,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-1', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '1 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -217,17 +225,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-2', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '2 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -235,17 +247,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-3', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '3 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -253,17 +269,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-4', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '4 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -271,17 +291,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-5', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '5 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -289,17 +313,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-6', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '6 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -307,17 +335,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-7', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '7 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -325,17 +357,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-8', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '8 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -343,17 +379,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel-block-9', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '9 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -366,16 +406,16 @@ export const SectionCarousel = { sectionId: 'cms-features-section-carousel2', numColumns: 3, title: { - type: 'heading2', + fieldType: 'heading2', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', }, callToAction: { - type: 'externalButtonLink', + fieldType: 'externalButtonLink', href: '#', label: 'Justo Tortor Amet', }, @@ -383,17 +423,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-1', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '1 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -401,17 +445,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-2', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '2 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -419,17 +467,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-3', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '3 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -437,17 +489,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-4', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '4 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -455,17 +511,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-5', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '5 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -473,17 +533,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-6', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '6 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -491,17 +555,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-7', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '7 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -509,17 +577,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-8', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '8 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -527,17 +599,21 @@ export const SectionCarousel = { { blockType: 'defaultBlock', blockId: 'cms-carousel2-block-9', - media: { type: 'image', alt: 'First image: 16:9', image: imagePlaceholder(576, 324) }, + media: { + fieldType: 'image', + alt: 'First image: 16:9', + image: imagePlaceholder(576, 324), + }, title: { - type: 'heading3', + fieldType: 'heading3', content: '9 Nullam id dolor id nibh ultricies vehicula ut id elit.', }, text: { - type: 'markdown', + fieldType: 'markdown', content: `Donec id elit non mi porta gravida at eget metus. Etiam porta sem malesuada magna mollis euismod. Nullam quis risus eget urna mollis ornare vel eu leo. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '#', label: 'Ultricies Elit Sem', }, @@ -557,10 +633,10 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block', numColumns: 1, - background: { type: 'customBackground', color: hexYellow }, - title: { type: 'heading2', content: 'One Column, No Blocks' }, + background: { fieldType: 'customBackground', color: hexYellow }, + title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, }, @@ -568,14 +644,14 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-dark', numColumns: 1, - background: { type: 'customBackground', color: hexBlack, textColor: 'light' }, - title: { type: 'heading2', content: 'One Column, No Blocks' }, + background: { fieldType: 'customBackground', color: hexBlack, textColor: 'light' }, + title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, callToAction: { - type: 'externalButtonLink', + fieldType: 'externalButtonLink', href: 'https://www.sharetribe.com/docs/', label: 'Flex Docs', }, @@ -585,19 +661,19 @@ export const SectionColumns = { sectionId: 'cms-column-section-no-block-bg-img', numColumns: 1, background: { - type: 'customBackground', + fieldType: 'customBackground', backgroundImage: imagePlaceholder(400, 400), alt: 'Background image', color: '#000000', textColor: 'white', }, - title: { type: 'heading2', content: 'One Column, No Blocks, Bg Image' }, + title: { fieldType: 'heading2', content: 'One Column, No Blocks, Bg Image' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, callToAction: { - type: 'externalButtonLink', + fieldType: 'externalButtonLink', href: 'https://www.sharetribe.com/docs/', label: 'Flex Docs', }, @@ -606,27 +682,27 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-1', numColumns: 1, - title: { type: 'heading2', content: 'One Column' }, + title: { fieldType: 'heading2', content: 'One Column' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-column1-block-1', - title: { type: 'heading3', content: 'Block 1' }, + title: { fieldType: 'heading3', content: 'Block 1' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, }, { blockType: 'defaultBlock', blockId: 'cms-column1-block-2', - title: { type: 'heading3', content: 'Block 2' }, + title: { fieldType: 'heading3', content: 'Block 2' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, }, @@ -636,22 +712,22 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-2', numColumns: 2, - title: { type: 'heading2', content: '2 Columns' }, + title: { fieldType: 'heading2', content: '2 Columns' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-column2-block-1', - title: { type: 'heading3', content: 'Column 1' }, + title: { fieldType: 'heading3', content: 'Column 1' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', label: 'See the sauna', }, @@ -659,13 +735,13 @@ export const SectionColumns = { { blockType: 'defaultBlock', blockId: 'cms-column2-block-2', - title: { type: 'heading3', content: 'Column 2' }, + title: { fieldType: 'heading3', content: 'Column 2' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', label: 'See the sauna', }, @@ -676,23 +752,23 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-2-dark', numColumns: 2, - background: { type: 'customBackground', color: hexBlack, textColor: 'light' }, - title: { type: 'heading2', content: '2 Columns, Dark' }, + background: { fieldType: 'customBackground', color: hexBlack, textColor: 'light' }, + title: { fieldType: 'heading2', content: '2 Columns, Dark' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-column2-block-1-dark', - title: { type: 'heading3', content: 'Column 1' }, + title: { fieldType: 'heading3', content: 'Column 1' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', label: 'See the sauna', }, @@ -700,13 +776,13 @@ export const SectionColumns = { { blockType: 'defaultBlock', blockId: 'cms-column2-block-2-dark', - title: { type: 'heading3', content: 'Column 2' }, + title: { fieldType: 'heading3', content: 'Column 2' }, text: { - type: 'markdown', + fieldType: 'markdown', content: `**Lorem ipsum** dolor sit amet consectetur adepisci elit...`, }, callToAction: { - type: 'internalButtonLink', + fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', label: 'See the sauna', }, @@ -717,29 +793,29 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-3', numColumns: 3, - title: { type: 'heading2', content: '3 Columns' }, + title: { fieldType: 'heading2', content: '3 Columns' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-column3-block-1', - media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, - title: { type: 'heading3', content: 'Image 1' }, + media: { fieldType: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + title: { fieldType: 'heading3', content: 'Image 1' }, }, { blockType: 'defaultBlock', blockId: 'cms-column3-block-2', - media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, - title: { type: 'heading3', content: 'Image 2' }, + media: { fieldType: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, + title: { fieldType: 'heading3', content: 'Image 2' }, }, { blockType: 'defaultBlock', blockId: 'cms-column3-block-3', - media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, - title: { type: 'heading3', content: 'Image 3' }, + media: { fieldType: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, + title: { fieldType: 'heading3', content: 'Image 3' }, }, ], }, @@ -747,35 +823,35 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-4', numColumns: 4, - title: { type: 'heading2', content: '4 Columns' }, + title: { fieldType: 'heading2', content: '4 Columns' }, ingress: { - type: 'paragraph', + fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-column4-block-1-variant-1', - media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, - title: { type: 'heading3', content: 'Image 1' }, + media: { fieldType: 'image', alt: 'First image', image: imagePlaceholder(400, 400) }, + title: { fieldType: 'heading3', content: 'Image 1' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-2-variant-1', - media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, - title: { type: 'heading3', content: 'Image 2' }, + media: { fieldType: 'image', alt: 'Second image', image: imagePlaceholder(400, 400) }, + title: { fieldType: 'heading3', content: 'Image 2' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-3-variant-1', - media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, - title: { type: 'heading3', content: 'Image 3' }, + media: { fieldType: 'image', alt: 'Third image', image: imagePlaceholder(400, 400) }, + title: { fieldType: 'heading3', content: 'Image 3' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-4-variant-1', - media: { type: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 400) }, - title: { type: 'heading3', content: 'Image 4' }, + media: { fieldType: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 400) }, + title: { fieldType: 'heading3', content: 'Image 4' }, }, ], }, @@ -783,38 +859,38 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-5', numColumns: 4, - title: { type: 'heading2', content: '4 Columns 5 blocks' }, - ingress: { type: 'paragraph', content: 'Portrait images (400x500)' }, + title: { fieldType: 'heading2', content: '4 Columns 5 blocks' }, + ingress: { fieldType: 'paragraph', content: 'Portrait images (400x500)' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-column4-block-1-variant-2', - media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 500) }, - title: { type: 'heading3', content: 'Image 1' }, + media: { fieldType: 'image', alt: 'First image', image: imagePlaceholder(400, 500) }, + title: { fieldType: 'heading3', content: 'Image 1' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-2-variant-2', - media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 500) }, - title: { type: 'heading3', content: 'Image 2' }, + media: { fieldType: 'image', alt: 'Second image', image: imagePlaceholder(400, 500) }, + title: { fieldType: 'heading3', content: 'Image 2' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-3-variant-2', - media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 500) }, - title: { type: 'heading3', content: 'Image 3' }, + media: { fieldType: 'image', alt: 'Third image', image: imagePlaceholder(400, 500) }, + title: { fieldType: 'heading3', content: 'Image 3' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-4-variant-2', - media: { type: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 500) }, - title: { type: 'heading3', content: 'Image 4' }, + media: { fieldType: 'image', alt: 'Fourth image', image: imagePlaceholder(400, 500) }, + title: { fieldType: 'heading3', content: 'Image 4' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-5-variant-2', - media: { type: 'image', alt: 'Fifth image', image: imagePlaceholder(400, 500) }, - title: { type: 'heading3', content: 'Image 5' }, + media: { fieldType: 'image', alt: 'Fifth image', image: imagePlaceholder(400, 500) }, + title: { fieldType: 'heading3', content: 'Image 5' }, }, ], }, @@ -822,26 +898,26 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-6', numColumns: 4, - title: { type: 'heading2', content: '4 Columns 3 blocks' }, - ingress: { type: 'paragraph', content: 'Landscape images (400x300)' }, + title: { fieldType: 'heading2', content: '4 Columns 3 blocks' }, + ingress: { fieldType: 'paragraph', content: 'Landscape images (400x300)' }, blocks: [ { blockType: 'defaultBlock', blockId: 'cms-column4-block-1-variant-3', - media: { type: 'image', alt: 'First image', image: imagePlaceholder(400, 300) }, - title: { type: 'heading3', content: 'Image 1' }, + media: { fieldType: 'image', alt: 'First image', image: imagePlaceholder(400, 300) }, + title: { fieldType: 'heading3', content: 'Image 1' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-2-variant-3', - media: { type: 'image', alt: 'Second image', image: imagePlaceholder(400, 300) }, - title: { type: 'heading3', content: 'Image 2' }, + media: { fieldType: 'image', alt: 'Second image', image: imagePlaceholder(400, 300) }, + title: { fieldType: 'heading3', content: 'Image 2' }, }, { blockType: 'defaultBlock', blockId: 'cms-column4-block-3-variant-3', - media: { type: 'image', alt: 'Third image', image: imagePlaceholder(400, 300) }, - title: { type: 'heading3', content: 'Image 3' }, + media: { fieldType: 'image', alt: 'Third image', image: imagePlaceholder(400, 300) }, + title: { fieldType: 'heading3', content: 'Image 3' }, }, ], }, diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js index 41379e0df..2d6bd10fa 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -15,7 +15,7 @@ const SectionContainer = props => { return ( <Tag className={classes} id={id} {...otherProps}> - {background?.type === 'customBackground' ? ( + {background?.fieldType === 'customBackground' ? ( <Field data={{ alt: `Background image for ${id}`, ...background }} className={className} diff --git a/src/containers/PrivacyPolicyPage/FallbackPage.js b/src/containers/PrivacyPolicyPage/FallbackPage.js index 59ef5c3f4..cfe1e528d 100644 --- a/src/containers/PrivacyPolicyPage/FallbackPage.js +++ b/src/containers/PrivacyPolicyPage/FallbackPage.js @@ -18,14 +18,14 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'privacy', - background: { type: 'customBackground', color: '#ffffff' }, - title: { type: 'heading1', content: 'Privacy Policy' }, + background: { fieldType: 'customBackground', color: '#ffffff' }, + title: { fieldType: 'heading1', content: 'Privacy Policy' }, blocks: [ { blockType: 'defaultBlock', blockId: 'hero-content', text: { - type: 'markdown', + fieldType: 'markdown', content: fallbackPrivacyPolicy, }, }, diff --git a/src/containers/TermsOfServicePage/FallbackPage.js b/src/containers/TermsOfServicePage/FallbackPage.js index 3d7b2cfc4..7f743780d 100644 --- a/src/containers/TermsOfServicePage/FallbackPage.js +++ b/src/containers/TermsOfServicePage/FallbackPage.js @@ -18,14 +18,14 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'terms', - background: { type: 'customBackground', color: '#ffffff' }, - title: { type: 'heading1', content: 'Terms of Service' }, + background: { fieldType: 'customBackground', color: '#ffffff' }, + title: { fieldType: 'heading1', content: 'Terms of Service' }, blocks: [ { blockType: 'defaultBlock', blockId: 'hero-content', text: { - type: 'markdown', + fieldType: 'markdown', content: fallbackTerms, }, }, From 1802ac317a039acf78113efe749eaf07cb47a09b Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 30 Jan 2023 14:59:58 +0200 Subject: [PATCH 81/99] Links (internalButtonLink and externalButtonLink) use content instead of label due to consistency --- .../PageBuilder/BlockBuilder/README.md | 2 +- .../PageBuilder/Field/Field.helpers.js | 8 +-- .../PageBuilder/Field/Field.helpers.test.js | 12 ++-- src/containers/PageBuilder/Field/Field.js | 2 +- .../SectionBuilder/SectionBuilder.example.js | 64 +++++++++---------- 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/containers/PageBuilder/BlockBuilder/README.md b/src/containers/PageBuilder/BlockBuilder/README.md index 089871d9d..8fe81a1a6 100644 --- a/src/containers/PageBuilder/BlockBuilder/README.md +++ b/src/containers/PageBuilder/BlockBuilder/README.md @@ -27,8 +27,8 @@ block type. }, callToAction: { fieldType: 'internalButtonLink', + content: 'Go to search page', href: '/s', - label: 'Go to search page', }, }, ]} diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index e0eb2b1d9..55fcd7cf9 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -28,16 +28,16 @@ export const exposeContentString = data => (hasContent(data) ? { content: data.c * Exposes "label" and "href" as "children" and "href" props respectively, * if both are of type string. Exposed "href" is sanitized. * - * @param {Object} data E.g. "{ fieldType: 'internalButtonLink', label: 'my title', href: 'https://my.domain.com' }" + * @param {Object} data E.g. "{ fieldType: 'internalButtonLink', content: 'my title', href: 'https://my.domain.com' }" * @returns object containing children and href. */ export const exposeLinkProps = data => { - const { label, href } = data; + const { content, href } = data; const hasCorrectProps = typeof href === 'string' && href.length > 0; // Sanitize the URL. See: src/utl/sanitize.js for more information. const cleanUrl = hasCorrectProps ? sanitizeUrl(href) : null; - // If no label is given, use href. - const linkText = typeof label === 'string' && label.length > 0 ? label : cleanUrl; + // If no content is given, use href. + const linkText = hasContent(data) ? content : cleanUrl; return cleanUrl ? { children: linkText, href: cleanUrl } : {}; }; diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index d6eec16ff..e5c7d0949 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -39,29 +39,29 @@ describe('Field helpers', () => { }); describe('exposeLinkProps(data)', () => { - it('should return only "label" and "href" props containing valid strings"', () => { + it('should return only "content" and "href" props containing valid strings"', () => { expect( - exposeLinkProps({ label: 'Hello world!', href: 'https://my.example.com/some/image.png' }) + exposeLinkProps({ content: 'Hello world!', href: 'https://my.example.com/some/image.png' }) ).toEqual({ children: 'Hello world!', href: 'https://my.example.com/some/image.png' }); expect( exposeLinkProps({ - label: 'Hello world!', + content: 'Hello world!', href: 'https://my.example.com/some/image.png', blaa: 'blaa', }) ).toEqual({ children: 'Hello world!', href: 'https://my.example.com/some/image.png' }); }); it('should return empty object if data is not valid', () => { - expect(exposeLinkProps({ label: 'Hello world!', blaa: 'blaa' })).toEqual({}); + expect(exposeLinkProps({ content: 'Hello world!', blaa: 'blaa' })).toEqual({}); }); it('should return href as "children" if label is not valid', () => { const href = 'https://my.example.com/some/image.png'; expect(exposeLinkProps({ href })).toEqual({ children: href, href: href }); - expect(exposeLinkProps({ label: 0, href })).toEqual({ children: href, href: href }); + expect(exposeLinkProps({ content: 0, href })).toEqual({ children: href, href: href }); }); it('should return "about:blank" in href if url in data is not valid', () => { expect( - exposeLinkProps({ label: 'Hello world!', href: "jav ascript:alert('XSS');" }) + exposeLinkProps({ content: 'Hello world!', href: "jav ascript:alert('XSS');" }) ).toEqual({ children: 'Hello world!', href: 'about:blank' }); }); }); diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index ecd5b101a..ea563d25c 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -186,7 +186,7 @@ const propTypeTextContent = shape({ const propTypeLink = shape({ fieldType: oneOf(['externalButtonLink', 'internalButtonLink']).isRequired, - label: string, + content: string, href: string.isRequired, }); diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index b2097f1be..f4edcefcf 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -46,7 +46,7 @@ export const SectionArticle = { callToAction: { fieldType: 'externalButtonLink', href: '#', - label: 'Justo Tortor Amet', + content: 'Justo Tortor Amet', }, blocks: [ { @@ -84,7 +84,7 @@ Vestibulum id ligula porta felis euismod semper. Cras justo odio, dapibus ac fac callToAction: { fieldType: 'externalButtonLink', href: 'https://www.sharetribe.com/academy/marketplace-funding/', - label: 'Read the article', + content: 'Read the article', }, }, ], @@ -113,7 +113,7 @@ export const SectionFeatures = { callToAction: { fieldType: 'externalButtonLink', href: '#', - label: 'Justo Tortor Amet', + content: 'Justo Tortor Amet', }, blocks: [ { @@ -131,7 +131,7 @@ export const SectionFeatures = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -149,7 +149,7 @@ export const SectionFeatures = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -167,7 +167,7 @@ export const SectionFeatures = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, ], @@ -197,7 +197,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'externalButtonLink', href: '#', - label: 'Justo Tortor Amet', + content: 'Justo Tortor Amet', }, blocks: [ { @@ -219,7 +219,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -241,7 +241,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -263,7 +263,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -285,7 +285,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -307,7 +307,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -329,7 +329,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -351,7 +351,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -373,7 +373,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -395,7 +395,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, ], @@ -417,7 +417,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'externalButtonLink', href: '#', - label: 'Justo Tortor Amet', + content: 'Justo Tortor Amet', }, blocks: [ { @@ -439,7 +439,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -461,7 +461,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -483,7 +483,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -505,7 +505,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -527,7 +527,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -549,7 +549,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -571,7 +571,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -593,7 +593,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, { @@ -615,7 +615,7 @@ export const SectionCarousel = { callToAction: { fieldType: 'internalButtonLink', href: '#', - label: 'Ultricies Elit Sem', + content: 'Ultricies Elit Sem', }, }, ], @@ -653,7 +653,7 @@ export const SectionColumns = { callToAction: { fieldType: 'externalButtonLink', href: 'https://www.sharetribe.com/docs/', - label: 'Flex Docs', + content: 'Flex Docs', }, }, { @@ -675,7 +675,7 @@ export const SectionColumns = { callToAction: { fieldType: 'externalButtonLink', href: 'https://www.sharetribe.com/docs/', - label: 'Flex Docs', + content: 'Flex Docs', }, }, { @@ -729,7 +729,7 @@ export const SectionColumns = { callToAction: { fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', - label: 'See the sauna', + content: 'See the sauna', }, }, { @@ -743,7 +743,7 @@ export const SectionColumns = { callToAction: { fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', - label: 'See the sauna', + content: 'See the sauna', }, }, ], @@ -770,7 +770,7 @@ export const SectionColumns = { callToAction: { fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', - label: 'See the sauna', + content: 'See the sauna', }, }, { @@ -784,7 +784,7 @@ export const SectionColumns = { callToAction: { fieldType: 'internalButtonLink', href: '/l/wooden-sauna/5aafa4ec-87c1-4043-b82f-14d67389dd19', - label: 'See the sauna', + content: 'See the sauna', }, }, ], From 0173a51601cd1177307ca0965843ae07bdee1c28 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 30 Jan 2023 15:11:59 +0200 Subject: [PATCH 82/99] Field types: customBackground and defaultBackground are renamed as customAppearance and defaultAppearance --- .../PageBuilder/Field/Field.helpers.js | 2 +- src/containers/PageBuilder/Field/Field.js | 16 ++++++++-------- src/containers/PageBuilder/Markdown.example.js | 4 ++-- .../PageBuilder/PageBuilder.example.js | 4 ++-- .../CustomAppearance.js} | 10 +++++----- .../CustomAppearance.module.css} | 0 .../Primitives/CustomAppearance/index.js | 1 + .../Primitives/CustomBackground/index.js | 1 - .../SectionBuilder/SectionBuilder.example.js | 8 ++++---- .../PageBuilder/SectionBuilder/SectionBuilder.js | 2 +- .../SectionContainer/SectionContainer.js | 2 +- src/containers/PrivacyPolicyPage/FallbackPage.js | 2 +- .../TermsOfServicePage/FallbackPage.js | 2 +- 13 files changed, 27 insertions(+), 27 deletions(-) rename src/containers/PageBuilder/Primitives/{CustomBackground/CustomBackground.js => CustomAppearance/CustomAppearance.js} (86%) rename src/containers/PageBuilder/Primitives/{CustomBackground/CustomBackground.module.css => CustomAppearance/CustomAppearance.module.css} (100%) create mode 100644 src/containers/PageBuilder/Primitives/CustomAppearance/index.js delete mode 100644 src/containers/PageBuilder/Primitives/CustomBackground/index.js diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index 55fcd7cf9..c66b82be6 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -113,7 +113,7 @@ const exposeColorValue = color => { * if backgroundImage contains imageAsset entity and * color contains hexadecimal string like "#FF0000" or "#F00". * - * @param {Object} data E.g. "{ fieldType: 'customBackground', backgroundImage: imageAssetRef, color: '#000000', textColor: '#FFFFFF' }" + * @param {Object} data E.g. "{ fieldType: 'customAppearance', backgroundImage: imageAssetRef, color: '#000000', textColor: '#FFFFFF' }" * @returns object containing valid data. */ export const exposeCustomBackgroundProps = data => { diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index ea563d25c..9fba28cac 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -11,7 +11,7 @@ import { P } from '../Primitives/P'; import { Code, CodeBlock } from '../Primitives/Code'; import { Link } from '../Primitives/Link'; import { MarkdownImage, FieldImage } from '../Primitives/Image'; -import { CustomBackground } from '../Primitives/CustomBackground'; +import { CustomAppearance } from '../Primitives/CustomAppearance'; import { YoutubeEmbed } from '../Primitives/YoutubeEmbed'; import renderMarkdown from '../markdownProcessor'; @@ -59,7 +59,7 @@ const defaultFieldComponents = { externalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, internalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, image: { component: FieldImage, pickValidProps: exposeImageProps }, - customBackground: { component: CustomBackground, pickValidProps: exposeCustomBackgroundProps }, + customAppearance: { component: CustomAppearance, pickValidProps: exposeCustomBackgroundProps }, youtube: { component: YoutubeEmbed, pickValidProps: exposeYoutubeProps }, // markdown content field is pretty complex component @@ -210,8 +210,8 @@ const propTypeImage = shape({ image: propTypeImageAsset.isRequired, }); -const propTypeCustomBackground = shape({ - fieldType: oneOf(['customBackground']).isRequired, +const propTypeCustomAppearance = shape({ + fieldType: oneOf(['customAppearance']).isRequired, color: string, textColor: string, backgroundImage: propTypeImageAsset, @@ -234,8 +234,8 @@ const propTypeEmptyObject = exact({}); const propTypeTextEmptyObject = exact({ fieldType: oneOf(TEXT_CONTENT).isRequired, }); -const propTypeDefaultBackground = shape({ - fieldType: oneOf(['defaultBackground']).isRequired, +const propTypeDefaultAppearance = shape({ + fieldType: oneOf(['defaultAppearance']).isRequired, }); const propTypeNone = shape({ fieldType: oneOf(['none']).isRequired, @@ -250,11 +250,11 @@ Field.propTypes = { propTypeTextContent, propTypeLink, propTypeImage, - propTypeCustomBackground, + propTypeCustomAppearance, propTypeYoutube, propTypeEmptyObject, propTypeTextEmptyObject, - propTypeDefaultBackground, + propTypeDefaultAppearance, propTypeNone, ]), options: propTypeOption, diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index 46fea0d9b..db949f476 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -517,7 +517,7 @@ const SectionLinksOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-3-dark', numColumns: 2, - background: { fieldType: 'customBackground', color: '#000000', textColor: 'white' }, + background: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, title: { fieldType: 'heading2', content: 'Links on dark theme' }, blocks: [ { @@ -539,7 +539,7 @@ const SectionCodeOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - background: { fieldType: 'customBackground', color: '#000000', textColor: 'white' }, + background: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, title: { fieldType: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index e23e40f69..9975643ca 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -167,7 +167,7 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns-section-1', numColumns: 2, - background: { fieldType: 'customBackground', color: hexYellow }, + background: { fieldType: 'customAppearance', color: hexYellow }, title: { fieldType: 'heading2', content: '2 Columns' }, ingress: { fieldType: 'paragraph', @@ -231,7 +231,7 @@ export const PageWithBuildInSectionColumns = { sectionId: 'page-builder-columns2-section-3', numColumns: 3, background: { - fieldType: 'customBackground', + fieldType: 'customAppearance', backgroundImage: imagePlaceholder(1200, 800, '#b6f7f9'), alt: 'Background image', color: '#000000', diff --git a/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js b/src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.js similarity index 86% rename from src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js rename to src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.js index 30398dd42..e4e617640 100644 --- a/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.js +++ b/src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.js @@ -4,10 +4,10 @@ import classNames from 'classnames'; import { ResponsiveImage } from '../../../../components/index.js'; -import css from './CustomBackground.module.css'; +import css from './CustomAppearance.module.css'; // BackgroundImage doesn't have enforcable aspectratio -export const CustomBackground = React.forwardRef((props, ref) => { +export const CustomAppearance = React.forwardRef((props, ref) => { const { className, rootClassName, color, alt, backgroundImage, sizes } = props; const getVariantNames = img => { @@ -34,9 +34,9 @@ export const CustomBackground = React.forwardRef((props, ref) => { ); }); -CustomBackground.displayName = 'CustomBackground'; +CustomAppearance.displayName = 'CustomAppearance'; -CustomBackground.defaultProps = { +CustomAppearance.defaultProps = { rootClassName: null, className: null, alt: 'background image', @@ -44,7 +44,7 @@ CustomBackground.defaultProps = { backgroundImage: null, }; -CustomBackground.propTypes = { +CustomAppearance.propTypes = { rootClassName: string, className: string, alt: string, diff --git a/src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.module.css b/src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.module.css similarity index 100% rename from src/containers/PageBuilder/Primitives/CustomBackground/CustomBackground.module.css rename to src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.module.css diff --git a/src/containers/PageBuilder/Primitives/CustomAppearance/index.js b/src/containers/PageBuilder/Primitives/CustomAppearance/index.js new file mode 100644 index 000000000..a2c4da7bd --- /dev/null +++ b/src/containers/PageBuilder/Primitives/CustomAppearance/index.js @@ -0,0 +1 @@ +export { CustomAppearance } from './CustomAppearance'; diff --git a/src/containers/PageBuilder/Primitives/CustomBackground/index.js b/src/containers/PageBuilder/Primitives/CustomBackground/index.js deleted file mode 100644 index b285a5201..000000000 --- a/src/containers/PageBuilder/Primitives/CustomBackground/index.js +++ /dev/null @@ -1 +0,0 @@ -export { CustomBackground } from './CustomBackground'; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index f4edcefcf..48f85a413 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -633,7 +633,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block', numColumns: 1, - background: { fieldType: 'customBackground', color: hexYellow }, + background: { fieldType: 'customAppearance', color: hexYellow }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { fieldType: 'paragraph', @@ -644,7 +644,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-dark', numColumns: 1, - background: { fieldType: 'customBackground', color: hexBlack, textColor: 'light' }, + background: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { fieldType: 'paragraph', @@ -661,7 +661,7 @@ export const SectionColumns = { sectionId: 'cms-column-section-no-block-bg-img', numColumns: 1, background: { - fieldType: 'customBackground', + fieldType: 'customAppearance', backgroundImage: imagePlaceholder(400, 400), alt: 'Background image', color: '#000000', @@ -752,7 +752,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-2-dark', numColumns: 2, - background: { fieldType: 'customBackground', color: hexBlack, textColor: 'light' }, + background: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, title: { fieldType: 'heading2', content: '2 Columns, Dark' }, ingress: { fieldType: 'paragraph', diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index c71378592..0bf2da85f 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -62,7 +62,7 @@ const SectionBuilder = props => { {sections.map((section, index) => { const Section = getComponent(section.sectionType); // If the default "dark" theme should be applied (when text color is white). - // By default, this information is stored to customBackground field + // By default, this information is stored to customAppearance field const isDarkTheme = section?.background?.textColor === 'white'; const classes = classNames({ [css.darkTheme]: isDarkTheme }); diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js index 2d6bd10fa..61bcf1ecf 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -15,7 +15,7 @@ const SectionContainer = props => { return ( <Tag className={classes} id={id} {...otherProps}> - {background?.fieldType === 'customBackground' ? ( + {background?.fieldType === 'customAppearance' ? ( <Field data={{ alt: `Background image for ${id}`, ...background }} className={className} diff --git a/src/containers/PrivacyPolicyPage/FallbackPage.js b/src/containers/PrivacyPolicyPage/FallbackPage.js index cfe1e528d..b8e3de316 100644 --- a/src/containers/PrivacyPolicyPage/FallbackPage.js +++ b/src/containers/PrivacyPolicyPage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'privacy', - background: { fieldType: 'customBackground', color: '#ffffff' }, + background: { fieldType: 'customAppearance', color: '#ffffff' }, title: { fieldType: 'heading1', content: 'Privacy Policy' }, blocks: [ { diff --git a/src/containers/TermsOfServicePage/FallbackPage.js b/src/containers/TermsOfServicePage/FallbackPage.js index 7f743780d..d8a2fa762 100644 --- a/src/containers/TermsOfServicePage/FallbackPage.js +++ b/src/containers/TermsOfServicePage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'terms', - background: { fieldType: 'customBackground', color: '#ffffff' }, + background: { fieldType: 'customAppearance', color: '#ffffff' }, title: { fieldType: 'heading1', content: 'Terms of Service' }, blocks: [ { From f69694b3d88c6b4505e1f57df441c80f5315dc42 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 30 Jan 2023 15:20:32 +0200 Subject: [PATCH 83/99] Field: rename background key as appearance --- .../PageBuilder/Field/Field.helpers.js | 4 +- .../PageBuilder/Field/Field.helpers.test.js | 50 +++++++++---------- src/containers/PageBuilder/Field/Field.js | 4 +- .../PageBuilder/Markdown.example.js | 4 +- .../PageBuilder/PageBuilder.example.js | 4 +- .../SectionArticle/SectionArticle.js | 8 +-- .../SectionBuilder/SectionBuilder.example.js | 8 +-- .../SectionBuilder/SectionBuilder.js | 2 +- .../SectionCarousel/SectionCarousel.js | 10 ++-- .../SectionColumns/SectionColumns.js | 10 ++-- .../SectionContainer/SectionContainer.js | 10 ++-- .../SectionFeatures/SectionFeatures.js | 10 ++-- .../PrivacyPolicyPage/FallbackPage.js | 2 +- .../TermsOfServicePage/FallbackPage.js | 2 +- 14 files changed, 61 insertions(+), 67 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index c66b82be6..686791a8a 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -109,14 +109,14 @@ const exposeColorValue = color => { }; /** - * Exposes background props like "backgroundImage", "color" property, + * Exposes appearance props like "backgroundImage", "color" property, * if backgroundImage contains imageAsset entity and * color contains hexadecimal string like "#FF0000" or "#F00". * * @param {Object} data E.g. "{ fieldType: 'customAppearance', backgroundImage: imageAssetRef, color: '#000000', textColor: '#FFFFFF' }" * @returns object containing valid data. */ -export const exposeCustomBackgroundProps = data => { +export const exposeCustomAppearanceProps = data => { const { backgroundImage, color, textColor, alt } = data; const { id, type, attributes } = backgroundImage || {}; diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index e5c7d0949..b699d1ab8 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -3,7 +3,7 @@ import { exposeContentString, exposeLinkProps, exposeImageProps, - exposeCustomBackgroundProps, + exposeCustomAppearanceProps, exposeYoutubeProps, } from './Field.helpers'; @@ -152,32 +152,32 @@ describe('Field helpers', () => { }); }); - describe('exposeCustomBackgroundProps(data)', () => { + describe('exposeCustomAppearanceProps(data)', () => { it('should return "color" prop containing valid hexadecimal color code', () => { - expect(exposeCustomBackgroundProps({ color: '#FFAA00' })).toEqual({ color: '#FFAA00' }); - expect(exposeCustomBackgroundProps({ color: '#FA0' })).toEqual({ color: '#FA0' }); - expect(exposeCustomBackgroundProps({ color: '#000000', foo: 'bar' })).toEqual({ + expect(exposeCustomAppearanceProps({ color: '#FFAA00' })).toEqual({ color: '#FFAA00' }); + expect(exposeCustomAppearanceProps({ color: '#FA0' })).toEqual({ color: '#FA0' }); + expect(exposeCustomAppearanceProps({ color: '#000000', foo: 'bar' })).toEqual({ color: '#000000', }); }); it('should return empty "color" prop if invalid hexadecimal color code was detected', () => { - expect(exposeCustomBackgroundProps({ color: '#FFAA0000' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: 'FA0' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: '000000' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: '#XX0000' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: '#FFAA0' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: 'rgb(100, 100, 100)' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: 'hsl(60 100% 50%)' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: 'hwb(90 10% 10%)' })).toEqual({}); - expect(exposeCustomBackgroundProps({ color: 'tomato' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: '#FFAA0000' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: 'FA0' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: '000000' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: '#XX0000' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: '#FFAA0' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: 'rgb(100, 100, 100)' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: 'hsl(60 100% 50%)' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: 'hwb(90 10% 10%)' })).toEqual({}); + expect(exposeCustomAppearanceProps({ color: 'tomato' })).toEqual({}); }); it('should return "textColor" prop containing valid value (light or dark)', () => { - expect(exposeCustomBackgroundProps({ textColor: 'light' })).toEqual({ textColor: 'light' }); - expect(exposeCustomBackgroundProps({ textColor: 'dark' })).toEqual({ textColor: 'dark' }); + expect(exposeCustomAppearanceProps({ textColor: 'light' })).toEqual({ textColor: 'light' }); + expect(exposeCustomAppearanceProps({ textColor: 'dark' })).toEqual({ textColor: 'dark' }); }); it('should return empty "textColor" prop if invalid hexadecimal color code was detected', () => { - expect(exposeCustomBackgroundProps({ textColor: 'blaa' })).toEqual({}); + expect(exposeCustomAppearanceProps({ textColor: 'blaa' })).toEqual({}); }); it('should return "backgroundImage" prop containing valid imageAsset', () => { @@ -200,8 +200,8 @@ describe('Field helpers', () => { }, }; const alt = 'gb'; - expect(exposeCustomBackgroundProps({ backgroundImage })).toEqual({ backgroundImage }); - expect(exposeCustomBackgroundProps({ backgroundImage, alt })).toEqual({ + expect(exposeCustomAppearanceProps({ backgroundImage })).toEqual({ backgroundImage }); + expect(exposeCustomAppearanceProps({ backgroundImage, alt })).toEqual({ backgroundImage, alt, }); @@ -236,10 +236,10 @@ describe('Field helpers', () => { }; const alt = 'gb'; const backgroundImage = backgroundImageWrongType; - expect(exposeCustomBackgroundProps({ backgroundImage })).toEqual({}); - expect(exposeCustomBackgroundProps({ backgroundImage, alt })).toEqual({}); - expect(exposeCustomBackgroundProps({ backgroundImage, color: '#FFAA00' })).toEqual({}); - expect(exposeCustomBackgroundProps({ backgroundImage: backgroundImageNoHeight })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundImage })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundImage, alt })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundImage, color: '#FFAA00' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundImage: backgroundImageNoHeight })).toEqual({}); }); it('should return partial prop if one of the props is invalid', () => { @@ -270,14 +270,14 @@ describe('Field helpers', () => { }, }; - const testA = exposeCustomBackgroundProps({ + const testA = exposeCustomAppearanceProps({ backgroundImage: backgroundImageNoHeight, color: '#FFAA00', }); expect(testA).toEqual({ color: '#FFAA00' }); const alt = 'gb'; - const testB = exposeCustomBackgroundProps({ backgroundImage, alt, color: 'tomato' }); + const testB = exposeCustomAppearanceProps({ backgroundImage, alt, color: 'tomato' }); expect(testB).toEqual({ backgroundImage, alt }); }); }); diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index 9fba28cac..b66f6023d 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -20,7 +20,7 @@ import { exposeContentAsChildren, exposeContentString, exposeLinkProps, - exposeCustomBackgroundProps, + exposeCustomAppearanceProps, exposeImageProps, exposeYoutubeProps, } from './Field.helpers'; @@ -59,7 +59,7 @@ const defaultFieldComponents = { externalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, internalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, image: { component: FieldImage, pickValidProps: exposeImageProps }, - customAppearance: { component: CustomAppearance, pickValidProps: exposeCustomBackgroundProps }, + customAppearance: { component: CustomAppearance, pickValidProps: exposeCustomAppearanceProps }, youtube: { component: YoutubeEmbed, pickValidProps: exposeYoutubeProps }, // markdown content field is pretty complex component diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index db949f476..a5d579315 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -517,7 +517,7 @@ const SectionLinksOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-3-dark', numColumns: 2, - background: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, + appearance: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, title: { fieldType: 'heading2', content: 'Links on dark theme' }, blocks: [ { @@ -539,7 +539,7 @@ const SectionCodeOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - background: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, + appearance: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, title: { fieldType: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index 9975643ca..1f7b3fffd 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -167,7 +167,7 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns-section-1', numColumns: 2, - background: { fieldType: 'customAppearance', color: hexYellow }, + appearance: { fieldType: 'customAppearance', color: hexYellow }, title: { fieldType: 'heading2', content: '2 Columns' }, ingress: { fieldType: 'paragraph', @@ -230,7 +230,7 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns2-section-3', numColumns: 3, - background: { + appearance: { fieldType: 'customAppearance', backgroundImage: imagePlaceholder(1200, 800, '#b6f7f9'), alt: 'Background image', diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index 3478eef88..2f31f7577 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -18,7 +18,7 @@ const SectionArticle = props => { defaultClasses, title, ingress, - background, + appearance, callToAction, blocks, isInsideContainer, @@ -38,7 +38,7 @@ const SectionArticle = props => { id={sectionId} className={className} rootClassName={rootClassName} - background={background} + appearance={appearance} options={fieldOptions} > {hasHeaderFields ? ( @@ -72,7 +72,7 @@ SectionArticle.defaultProps = { textClassName: null, title: null, ingress: null, - background: null, + appearance: null, callToAction: null, blocks: [], isInsideContainer: false, @@ -91,7 +91,7 @@ SectionArticle.propTypes = { }), title: object, ingress: object, - background: object, + appearance: object, callToAction: object, blocks: arrayOf(object), isInsideContainer: bool, diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index 48f85a413..253370302 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -633,7 +633,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block', numColumns: 1, - background: { fieldType: 'customAppearance', color: hexYellow }, + appearance: { fieldType: 'customAppearance', color: hexYellow }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { fieldType: 'paragraph', @@ -644,7 +644,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-dark', numColumns: 1, - background: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, + appearance: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { fieldType: 'paragraph', @@ -660,7 +660,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-bg-img', numColumns: 1, - background: { + appearance: { fieldType: 'customAppearance', backgroundImage: imagePlaceholder(400, 400), alt: 'Background image', @@ -752,7 +752,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-2-dark', numColumns: 2, - background: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, + appearance: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, title: { fieldType: 'heading2', content: '2 Columns, Dark' }, ingress: { fieldType: 'paragraph', diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index 0bf2da85f..d12458f8b 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -63,7 +63,7 @@ const SectionBuilder = props => { const Section = getComponent(section.sectionType); // If the default "dark" theme should be applied (when text color is white). // By default, this information is stored to customAppearance field - const isDarkTheme = section?.background?.textColor === 'white'; + const isDarkTheme = section?.appearance?.textColor === 'white'; const classes = classNames({ [css.darkTheme]: isDarkTheme }); if (Section) { diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index 324213b29..8424e1f6d 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -39,7 +39,7 @@ const SectionCarousel = props => { numColumns, title, ingress, - background, + appearance, callToAction, blocks, options, @@ -104,7 +104,7 @@ const SectionCarousel = props => { id={sectionId} className={className} rootClassName={rootClassName} - background={background} + appearance={appearance} options={fieldOptions} > {hasHeaderFields ? ( @@ -155,8 +155,7 @@ SectionCarousel.defaultProps = { numColumns: 1, title: null, ingress: null, - background: null, - backgroundImage: null, + appearance: null, callToAction: null, blocks: [], options: null, @@ -175,8 +174,7 @@ SectionCarousel.propTypes = { numColumns: number, title: object, ingress: object, - background: object, - backgroundImage: object, + appearance: object, callToAction: object, blocks: arrayOf(object), options: propTypeOption, diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js index 1a791bdc6..80884697e 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js @@ -35,7 +35,7 @@ const SectionColumns = props => { numColumns, title, ingress, - background, + appearance, callToAction, blocks, isInsideContainer, @@ -55,7 +55,7 @@ const SectionColumns = props => { id={sectionId} className={className} rootClassName={rootClassName} - background={background} + appearance={appearance} options={fieldOptions} > {hasHeaderFields ? ( @@ -95,8 +95,7 @@ SectionColumns.defaultProps = { numColumns: 1, title: null, ingress: null, - background: null, - backgroundImage: null, + appearance: null, callToAction: null, blocks: [], isInsideContainer: false, @@ -116,8 +115,7 @@ SectionColumns.propTypes = { numColumns: number, title: object, ingress: object, - background: object, - backgroundImage: object, + appearance: object, callToAction: object, blocks: arrayOf(object), isInsideContainer: bool, diff --git a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js index 61bcf1ecf..c10919093 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionContainer/SectionContainer.js @@ -9,15 +9,15 @@ import css from './SectionContainer.module.css'; // This component can be used to wrap some common styles and features of Section-level components. // E.g: const SectionHero = props => (<SectionContainer><H1>Hello World!</H1></SectionContainer>); const SectionContainer = props => { - const { className, rootClassName, id, as, children, background, options, ...otherProps } = props; + const { className, rootClassName, id, as, children, appearance, options, ...otherProps } = props; const Tag = as || 'section'; const classes = classNames(rootClassName || css.root, className); return ( <Tag className={classes} id={id} {...otherProps}> - {background?.fieldType === 'customAppearance' ? ( + {appearance?.fieldType === 'customAppearance' ? ( <Field - data={{ alt: `Background image for ${id}`, ...background }} + data={{ alt: `Background image for ${id}`, ...appearance }} className={className} options={options} /> @@ -37,7 +37,7 @@ SectionContainer.defaultProps = { className: null, as: 'div', children: null, - background: null, + appearance: null, }; SectionContainer.propTypes = { @@ -45,7 +45,7 @@ SectionContainer.propTypes = { className: string, as: string, children: node, - background: object, + appearance: object, options: propTypeOption, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js index 889f43c5a..bd7c702e6 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -22,7 +22,7 @@ const SectionFeatures = props => { defaultClasses, title, ingress, - background, + appearance, callToAction, blocks, isInsideContainer, @@ -42,7 +42,7 @@ const SectionFeatures = props => { id={sectionId} className={className} rootClassName={rootClassName} - background={background} + appearance={appearance} options={fieldOptions} > {hasHeaderFields ? ( @@ -81,8 +81,7 @@ SectionFeatures.defaultProps = { textClassName: null, title: null, ingress: null, - background: null, - backgroundImage: null, + appearance: null, callToAction: null, blocks: [], isInsideContainer: false, @@ -101,8 +100,7 @@ SectionFeatures.propTypes = { }), title: object, ingress: object, - background: object, - backgroundImage: object, + appearance: object, callToAction: object, blocks: arrayOf(object), isInsideContainer: bool, diff --git a/src/containers/PrivacyPolicyPage/FallbackPage.js b/src/containers/PrivacyPolicyPage/FallbackPage.js index b8e3de316..26ebba293 100644 --- a/src/containers/PrivacyPolicyPage/FallbackPage.js +++ b/src/containers/PrivacyPolicyPage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'privacy', - background: { fieldType: 'customAppearance', color: '#ffffff' }, + appearance: { fieldType: 'customAppearance', color: '#ffffff' }, title: { fieldType: 'heading1', content: 'Privacy Policy' }, blocks: [ { diff --git a/src/containers/TermsOfServicePage/FallbackPage.js b/src/containers/TermsOfServicePage/FallbackPage.js index d8a2fa762..0e74f86b3 100644 --- a/src/containers/TermsOfServicePage/FallbackPage.js +++ b/src/containers/TermsOfServicePage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'terms', - background: { fieldType: 'customAppearance', color: '#ffffff' }, + appearance: { fieldType: 'customAppearance', color: '#ffffff' }, title: { fieldType: 'heading1', content: 'Terms of Service' }, blocks: [ { From 9c6bad335ffc245c2f989a191f508b26c5d8eaba Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 30 Jan 2023 15:43:59 +0200 Subject: [PATCH 84/99] Field type customAppearance: rename color as backgroundColor --- .../PageBuilder/Field/Field.helpers.js | 16 ++++--- .../PageBuilder/Field/Field.helpers.test.js | 48 +++++++++++-------- src/containers/PageBuilder/Field/Field.js | 2 +- .../PageBuilder/Markdown.example.js | 4 +- .../PageBuilder/PageBuilder.example.js | 4 +- .../CustomAppearance/CustomAppearance.js | 8 ++-- .../SectionBuilder/SectionBuilder.example.js | 16 +++++-- 7 files changed, 60 insertions(+), 38 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index 686791a8a..316e4e26e 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -109,24 +109,26 @@ const exposeColorValue = color => { }; /** - * Exposes appearance props like "backgroundImage", "color" property, + * Exposes appearance props like "backgroundImage", "backgroundColor" property, * if backgroundImage contains imageAsset entity and - * color contains hexadecimal string like "#FF0000" or "#F00". + * backgroundColor contains hexadecimal string like "#FF0000" or "#F00". * - * @param {Object} data E.g. "{ fieldType: 'customAppearance', backgroundImage: imageAssetRef, color: '#000000', textColor: '#FFFFFF' }" + * @param {Object} data E.g. "{ fieldType: 'customAppearance', backgroundImage: imageAssetRef, backgroundColor: '#000000', textColor: '#FFFFFF' }" * @returns object containing valid data. */ export const exposeCustomAppearanceProps = data => { - const { backgroundImage, color, textColor, alt } = data; + const { backgroundImage, backgroundColor, textColor, alt } = data; const { id, type, attributes } = backgroundImage || {}; if (!!type && type !== 'imageAsset') { return {}; } - const validColor = exposeColorValue(color); - const isValidColor = !!validColor; - const backgroundColorMaybe = isValidColor ? { color: validColor } : {}; + const validBackgroundColor = exposeColorValue(backgroundColor); + const isValidBackgroundColor = !!validBackgroundColor; + const backgroundColorMaybe = isValidBackgroundColor + ? { backgroundColor: validBackgroundColor } + : {}; const isValidTextColor = ['light', 'dark'].includes(textColor); const textColorMaybe = isValidTextColor ? { textColor } : {}; diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index b699d1ab8..60891008b 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -153,23 +153,27 @@ describe('Field helpers', () => { }); describe('exposeCustomAppearanceProps(data)', () => { - it('should return "color" prop containing valid hexadecimal color code', () => { - expect(exposeCustomAppearanceProps({ color: '#FFAA00' })).toEqual({ color: '#FFAA00' }); - expect(exposeCustomAppearanceProps({ color: '#FA0' })).toEqual({ color: '#FA0' }); - expect(exposeCustomAppearanceProps({ color: '#000000', foo: 'bar' })).toEqual({ - color: '#000000', + it('should return "backgroundColor" prop containing valid hexadecimal color code', () => { + expect(exposeCustomAppearanceProps({ backgroundColor: '#FFAA00' })).toEqual({ + backgroundColor: '#FFAA00', + }); + expect(exposeCustomAppearanceProps({ backgroundColor: '#FA0' })).toEqual({ + backgroundColor: '#FA0', + }); + expect(exposeCustomAppearanceProps({ backgroundColor: '#000000', foo: 'bar' })).toEqual({ + backgroundColor: '#000000', }); }); - it('should return empty "color" prop if invalid hexadecimal color code was detected', () => { - expect(exposeCustomAppearanceProps({ color: '#FFAA0000' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: 'FA0' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: '000000' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: '#XX0000' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: '#FFAA0' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: 'rgb(100, 100, 100)' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: 'hsl(60 100% 50%)' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: 'hwb(90 10% 10%)' })).toEqual({}); - expect(exposeCustomAppearanceProps({ color: 'tomato' })).toEqual({}); + it('should return empty "backgroundColor" prop if invalid hexadecimal color code was detected', () => { + expect(exposeCustomAppearanceProps({ backgroundColor: '#FFAA0000' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: 'FA0' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: '000000' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: '#XX0000' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: '#FFAA0' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: 'rgb(100, 100, 100)' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: 'hsl(60 100% 50%)' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: 'hwb(90 10% 10%)' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundColor: 'tomato' })).toEqual({}); }); it('should return "textColor" prop containing valid value (light or dark)', () => { @@ -238,7 +242,9 @@ describe('Field helpers', () => { const backgroundImage = backgroundImageWrongType; expect(exposeCustomAppearanceProps({ backgroundImage })).toEqual({}); expect(exposeCustomAppearanceProps({ backgroundImage, alt })).toEqual({}); - expect(exposeCustomAppearanceProps({ backgroundImage, color: '#FFAA00' })).toEqual({}); + expect(exposeCustomAppearanceProps({ backgroundImage, backgroundColor: '#FFAA00' })).toEqual( + {} + ); expect(exposeCustomAppearanceProps({ backgroundImage: backgroundImageNoHeight })).toEqual({}); }); @@ -272,12 +278,16 @@ describe('Field helpers', () => { const testA = exposeCustomAppearanceProps({ backgroundImage: backgroundImageNoHeight, - color: '#FFAA00', + backgroundColor: '#FFAA00', }); - expect(testA).toEqual({ color: '#FFAA00' }); + expect(testA).toEqual({ backgroundColor: '#FFAA00' }); const alt = 'gb'; - const testB = exposeCustomAppearanceProps({ backgroundImage, alt, color: 'tomato' }); + const testB = exposeCustomAppearanceProps({ + backgroundImage, + alt, + backgroundColor: 'tomato', + }); expect(testB).toEqual({ backgroundImage, alt }); }); }); diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index b66f6023d..73327ac15 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -212,7 +212,7 @@ const propTypeImage = shape({ const propTypeCustomAppearance = shape({ fieldType: oneOf(['customAppearance']).isRequired, - color: string, + backgroundColor: string, textColor: string, backgroundImage: propTypeImageAsset, }); diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index a5d579315..e8b730427 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -517,7 +517,7 @@ const SectionLinksOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-3-dark', numColumns: 2, - appearance: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, + appearance: { fieldType: 'customAppearance', backgroundColor: '#000000', textColor: 'white' }, title: { fieldType: 'heading2', content: 'Links on dark theme' }, blocks: [ { @@ -539,7 +539,7 @@ const SectionCodeOnDarkMode = { sectionType: 'columns', sectionId: 'cms-section-8', numColumns: 2, - appearance: { fieldType: 'customAppearance', color: '#000000', textColor: 'white' }, + appearance: { fieldType: 'customAppearance', backgroundColor: '#000000', textColor: 'white' }, title: { fieldType: 'heading2', content: 'Inline code and Code blocks' }, blocks: [ { diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index 1f7b3fffd..21093067d 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -167,7 +167,7 @@ export const PageWithBuildInSectionColumns = { sectionType: 'columns', sectionId: 'page-builder-columns-section-1', numColumns: 2, - appearance: { fieldType: 'customAppearance', color: hexYellow }, + appearance: { fieldType: 'customAppearance', backgroundColor: hexYellow }, title: { fieldType: 'heading2', content: '2 Columns' }, ingress: { fieldType: 'paragraph', @@ -234,7 +234,7 @@ export const PageWithBuildInSectionColumns = { fieldType: 'customAppearance', backgroundImage: imagePlaceholder(1200, 800, '#b6f7f9'), alt: 'Background image', - color: '#000000', + backgroundColor: '#000000', textColor: 'white', }, title: { fieldType: 'heading2', content: '3 Columns' }, diff --git a/src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.js b/src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.js index e4e617640..6dcb38cc3 100644 --- a/src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.js +++ b/src/containers/PageBuilder/Primitives/CustomAppearance/CustomAppearance.js @@ -8,14 +8,14 @@ import css from './CustomAppearance.module.css'; // BackgroundImage doesn't have enforcable aspectratio export const CustomAppearance = React.forwardRef((props, ref) => { - const { className, rootClassName, color, alt, backgroundImage, sizes } = props; + const { className, rootClassName, backgroundColor, backgroundImage, alt, sizes } = props; const getVariantNames = img => { const { variants } = img?.attributes || {}; return variants ? Object.keys(variants) : []; }; - const backgroundColorMaybe = color ? { backgroundColor: color } : {}; + const backgroundColorMaybe = backgroundColor ? { backgroundColor } : {}; const classes = classNames(rootClassName || css.backgroundImageWrapper, className); return ( @@ -41,13 +41,14 @@ CustomAppearance.defaultProps = { className: null, alt: 'background image', sizes: null, + backgroundColor: null, backgroundImage: null, }; CustomAppearance.propTypes = { rootClassName: string, className: string, - alt: string, + backgroundColor: string, backgroundImage: shape({ id: string.isRequired, type: oneOf(['imageAsset']).isRequired, @@ -61,5 +62,6 @@ CustomAppearance.propTypes = { ).isRequired, }).isRequired, }), + alt: string, sizes: string, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index 253370302..5ae875363 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -633,7 +633,7 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block', numColumns: 1, - appearance: { fieldType: 'customAppearance', color: hexYellow }, + appearance: { fieldType: 'customAppearance', backgroundColor: hexYellow }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { fieldType: 'paragraph', @@ -644,7 +644,11 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-no-block-dark', numColumns: 1, - appearance: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, + appearance: { + fieldType: 'customAppearance', + backgroundColor: hexBlack, + textColor: 'light', + }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, ingress: { fieldType: 'paragraph', @@ -664,7 +668,7 @@ export const SectionColumns = { fieldType: 'customAppearance', backgroundImage: imagePlaceholder(400, 400), alt: 'Background image', - color: '#000000', + backgroundColor: '#000000', textColor: 'white', }, title: { fieldType: 'heading2', content: 'One Column, No Blocks, Bg Image' }, @@ -752,7 +756,11 @@ export const SectionColumns = { sectionType: 'columns', sectionId: 'cms-column-section-2-dark', numColumns: 2, - appearance: { fieldType: 'customAppearance', color: hexBlack, textColor: 'light' }, + appearance: { + fieldType: 'customAppearance', + backgroundColor: hexBlack, + textColor: 'light', + }, title: { fieldType: 'heading2', content: '2 Columns, Dark' }, ingress: { fieldType: 'paragraph', From 3f771f3d89741706a7c34ed4554f0963a4628c4f Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 30 Jan 2023 18:10:07 +0200 Subject: [PATCH 85/99] Omit invalid props warning from field types: headings, paragraph, markdown Removing content input results as empty string in the saved asset. --- .../PageBuilder/Field/Field.helpers.js | 2 +- .../PageBuilder/Field/Field.helpers.test.js | 13 ++++++++++ src/containers/PageBuilder/Field/Field.js | 26 +++++++++++++------ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index 316e4e26e..b11f878bc 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -4,7 +4,7 @@ import { sanitizeUrl } from '../../../util/sanitize'; // Pickers for valid props // ///////////////////////////// -const hasContent = data => typeof data?.content === 'string' && data?.content.length > 0; +export const hasContent = data => typeof data?.content === 'string' && data?.content.length > 0; /** * Exposes "content" prop as children property, if "content" has type of string. diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index 60891008b..71d4e07a2 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -1,4 +1,5 @@ import { + hasContent, exposeContentAsChildren, exposeContentString, exposeLinkProps, @@ -8,6 +9,18 @@ import { } from './Field.helpers'; describe('Field helpers', () => { + describe('hasContent(data)', () => { + it('should return true if data has "content"', () => { + expect(hasContent({ content: 'Hello world!' })).toEqual(true); + expect(hasContent({ content: 'Hello world!', blaa: 'blaa' })).toEqual(true); + }); + + it('should return false if "content" is not included or if it is empty string', () => { + expect(hasContent({ foo: 'bar' })).toEqual(false); + expect(hasContent({ content: '' })).toEqual(false); + }); + }); + describe('exposeContentAsChildren(data)', () => { it('should return only "children" prop containing the string from passed-in "content"', () => { expect(exposeContentAsChildren({ content: 'Hello world!' })).toEqual({ diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index 73327ac15..8792976dd 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -17,6 +17,7 @@ import { YoutubeEmbed } from '../Primitives/YoutubeEmbed'; import renderMarkdown from '../markdownProcessor'; import { + hasContent, exposeContentAsChildren, exposeContentString, exposeLinkProps, @@ -48,14 +49,22 @@ const MarkdownField = ({ content, components }) => renderMarkdown(content, compo // Mapping of field types and components // /////////////////////////////////////////// +// For text content (headings, paragraph, markdown), we don't print warning about empty string +// as that's expected result after removing previously entered string. +const omitInvalidPropsWarning = data => !hasContent(data); + const defaultFieldComponents = { - heading1: { component: H1, pickValidProps: exposeContentAsChildren }, - heading2: { component: H2, pickValidProps: exposeContentAsChildren }, - heading3: { component: H3, pickValidProps: exposeContentAsChildren }, - heading4: { component: H4, pickValidProps: exposeContentAsChildren }, - heading5: { component: H5, pickValidProps: exposeContentAsChildren }, - heading6: { component: H6, pickValidProps: exposeContentAsChildren }, - paragraph: { component: Ingress, pickValidProps: exposeContentAsChildren }, + heading1: { component: H1, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, + heading2: { component: H2, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, + heading3: { component: H3, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, + heading4: { component: H4, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, + heading5: { component: H5, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, + heading6: { component: H6, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, + paragraph: { + component: Ingress, + pickValidProps: exposeContentAsChildren, + omitInvalidPropsWarning, + }, externalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, internalButtonLink: { component: Link, pickValidProps: exposeLinkProps }, image: { component: FieldImage, pickValidProps: exposeImageProps }, @@ -122,10 +131,11 @@ export const validProps = (data, options) => { const pickValidProps = config?.pickValidProps; if (data && pickValidProps) { const validProps = pickValidProps(data); + const omitWarning = config?.omitInvalidPropsWarning && config?.omitInvalidPropsWarning(data); // If picker returns an empty object, data was invalid. // Field will render null, but we should warn the dev that data was not valid. - if (Object.keys(validProps).length === 0) { + if (Object.keys(validProps).length === 0 && !omitWarning) { console.warn(`Invalid props detected. Data: ${JSON.stringify(data)}`); } return validProps; From c3587d99caf6786c393c4ca1742502df65a02ecc Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 31 Jan 2023 19:38:18 +0200 Subject: [PATCH 86/99] Change in page asset schema: ingress -> description --- src/containers/PageBuilder/Field/Field.js | 2 ++ .../PageBuilder/Markdown.example.js | 2 +- .../PageBuilder/PageBuilder.example.js | 10 +++---- .../PageBuilder/Primitives/Ingress/Ingress.js | 2 ++ .../SectionArticle/SectionArticle.js | 12 ++++---- .../SectionBuilder/SectionBuilder.example.js | 28 +++++++++---------- .../SectionBuilder/SectionBuilder.js | 2 +- .../SectionBuilder/SectionBuilder.module.css | 2 +- .../SectionCarousel/SectionCarousel.js | 12 ++++---- .../SectionColumns/SectionColumns.js | 12 ++++---- .../SectionFeatures/SectionFeatures.js | 12 ++++---- 11 files changed, 50 insertions(+), 46 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index 8792976dd..c16d68bc8 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -61,6 +61,8 @@ const defaultFieldComponents = { heading5: { component: H5, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, heading6: { component: H6, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning }, paragraph: { + // By default, page asset schema uses 'paragraph' field type only in the context of + // lead paragraph aka ingress component: Ingress, pickValidProps: exposeContentAsChildren, omitInvalidPropsWarning, diff --git a/src/containers/PageBuilder/Markdown.example.js b/src/containers/PageBuilder/Markdown.example.js index e8b730427..4de065c45 100644 --- a/src/containers/PageBuilder/Markdown.example.js +++ b/src/containers/PageBuilder/Markdown.example.js @@ -32,7 +32,7 @@ const SectionHeadings = { sectionId: 'cms-section-1', numColumns: 2, title: { fieldType: 'heading2', content: 'Headings' }, - ingress: { + description: { fieldType: 'heading2', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, diff --git a/src/containers/PageBuilder/PageBuilder.example.js b/src/containers/PageBuilder/PageBuilder.example.js index 21093067d..d6c5ade16 100644 --- a/src/containers/PageBuilder/PageBuilder.example.js +++ b/src/containers/PageBuilder/PageBuilder.example.js @@ -147,7 +147,7 @@ export const PageWithBuildInSectionColumns = { sectionId: 'page-builder-columns-section-0', numColumns: 1, title: { fieldType: 'heading2', content: 'One Column' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -169,7 +169,7 @@ export const PageWithBuildInSectionColumns = { numColumns: 2, appearance: { fieldType: 'customAppearance', backgroundColor: hexYellow }, title: { fieldType: 'heading2', content: '2 Columns' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -199,7 +199,7 @@ export const PageWithBuildInSectionColumns = { sectionId: 'page-builder-columns-section-2', numColumns: 2, title: { fieldType: 'heading2', content: '2 Columns' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -238,7 +238,7 @@ export const PageWithBuildInSectionColumns = { textColor: 'white', }, title: { fieldType: 'heading2', content: '3 Columns' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -280,7 +280,7 @@ export const PageWithBuildInSectionColumns = { sectionId: 'page-builder-columns2-section-4', numColumns: 4, title: { fieldType: 'heading2', content: '4 Columns' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, diff --git a/src/containers/PageBuilder/Primitives/Ingress/Ingress.js b/src/containers/PageBuilder/Primitives/Ingress/Ingress.js index 0e90600c2..da7951e5f 100644 --- a/src/containers/PageBuilder/Primitives/Ingress/Ingress.js +++ b/src/containers/PageBuilder/Primitives/Ingress/Ingress.js @@ -4,6 +4,8 @@ import classNames from 'classnames'; import css from './Ingress.module.css'; +// Ingress: a lead paragraph or an opening paragraph +// It's usually between a headline and the article export const Ingress = React.forwardRef((props, ref) => { const { className, rootClassName, ...otherProps } = props; const classes = classNames(rootClassName || css.ingress, className); diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index 2f31f7577..7ed58e663 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -17,7 +17,7 @@ const SectionArticle = props => { rootClassName, defaultClasses, title, - ingress, + description, appearance, callToAction, blocks, @@ -30,7 +30,7 @@ const SectionArticle = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); + const hasHeaderFields = hasDataInFields([title, description, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; return ( @@ -44,7 +44,7 @@ const SectionArticle = props => { {hasHeaderFields ? ( <header className={defaultClasses.sectionDetails}> <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={description} className={defaultClasses.description} options={fieldOptions} /> <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> </header> ) : null} @@ -71,7 +71,7 @@ SectionArticle.defaultProps = { defaultClasses: null, textClassName: null, title: null, - ingress: null, + description: null, appearance: null, callToAction: null, blocks: [], @@ -86,11 +86,11 @@ SectionArticle.propTypes = { defaultClasses: shape({ sectionDetails: string, title: string, - ingress: string, + description: string, ctaButton: string, }), title: object, - ingress: object, + description: object, appearance: object, callToAction: object, blocks: arrayOf(object), diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js index 5ae875363..bfc5a2c4b 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.example.js @@ -38,7 +38,7 @@ export const SectionArticle = { fieldType: 'heading1', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, - ingress: { + description: { fieldType: 'paragraph', content: 'Maecenas sed diam eget risus varius blandit sit amet non magna. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', @@ -105,7 +105,7 @@ export const SectionFeatures = { fieldType: 'heading1', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, - ingress: { + description: { fieldType: 'paragraph', content: 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', @@ -189,7 +189,7 @@ export const SectionCarousel = { fieldType: 'heading2', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, - ingress: { + description: { fieldType: 'paragraph', content: 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', @@ -409,7 +409,7 @@ export const SectionCarousel = { fieldType: 'heading2', content: 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.', }, - ingress: { + description: { fieldType: 'paragraph', content: 'Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Nullam id dolor id nibh ultricies vehicula ut id elit.', @@ -635,7 +635,7 @@ export const SectionColumns = { numColumns: 1, appearance: { fieldType: 'customAppearance', backgroundColor: hexYellow }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -650,7 +650,7 @@ export const SectionColumns = { textColor: 'light', }, title: { fieldType: 'heading2', content: 'One Column, No Blocks' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -672,7 +672,7 @@ export const SectionColumns = { textColor: 'white', }, title: { fieldType: 'heading2', content: 'One Column, No Blocks, Bg Image' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -687,7 +687,7 @@ export const SectionColumns = { sectionId: 'cms-column-section-1', numColumns: 1, title: { fieldType: 'heading2', content: 'One Column' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -717,7 +717,7 @@ export const SectionColumns = { sectionId: 'cms-column-section-2', numColumns: 2, title: { fieldType: 'heading2', content: '2 Columns' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -762,7 +762,7 @@ export const SectionColumns = { textColor: 'light', }, title: { fieldType: 'heading2', content: '2 Columns, Dark' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -802,7 +802,7 @@ export const SectionColumns = { sectionId: 'cms-column-section-3', numColumns: 3, title: { fieldType: 'heading2', content: '3 Columns' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -832,7 +832,7 @@ export const SectionColumns = { sectionId: 'cms-column-section-4', numColumns: 4, title: { fieldType: 'heading2', content: '4 Columns' }, - ingress: { + description: { fieldType: 'paragraph', content: 'Lorem ipsum dolor sit amet consectetur adepisci elit...', }, @@ -868,7 +868,7 @@ export const SectionColumns = { sectionId: 'cms-column-section-5', numColumns: 4, title: { fieldType: 'heading2', content: '4 Columns 5 blocks' }, - ingress: { fieldType: 'paragraph', content: 'Portrait images (400x500)' }, + description: { fieldType: 'paragraph', content: 'Portrait images (400x500)' }, blocks: [ { blockType: 'defaultBlock', @@ -907,7 +907,7 @@ export const SectionColumns = { sectionId: 'cms-column-section-6', numColumns: 4, title: { fieldType: 'heading2', content: '4 Columns 3 blocks' }, - ingress: { fieldType: 'paragraph', content: 'Landscape images (400x300)' }, + description: { fieldType: 'paragraph', content: 'Landscape images (400x300)' }, blocks: [ { blockType: 'defaultBlock', diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js index d12458f8b..be6683533 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.js @@ -21,7 +21,7 @@ import css from './SectionBuilder.module.css'; const DEFAULT_CLASSES = { sectionDetails: css.sectionDetails, title: css.title, - ingress: css.ingress, + description: css.description, ctaButton: css.ctaButton, blockContainer: css.blockContainer, }; diff --git a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css index 84e1e3d94..5e990a6fc 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionBuilder.module.css @@ -127,7 +127,7 @@ max-width: 30ch; } -.ingress { +.description { composes: align; max-width: 65ch; } diff --git a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js index 8424e1f6d..80f81a462 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionCarousel/SectionCarousel.js @@ -38,7 +38,7 @@ const SectionCarousel = props => { defaultClasses, numColumns, title, - ingress, + description, appearance, callToAction, blocks, @@ -69,7 +69,7 @@ const SectionCarousel = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); + const hasHeaderFields = hasDataInFields([title, description, callToAction], fieldOptions); const onSlideLeft = e => { var slider = window.document.getElementById(sliderId); @@ -110,7 +110,7 @@ const SectionCarousel = props => { {hasHeaderFields ? ( <header className={defaultClasses.sectionDetails}> <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={description} className={defaultClasses.description} options={fieldOptions} /> <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> </header> ) : null} @@ -154,7 +154,7 @@ SectionCarousel.defaultProps = { textClassName: null, numColumns: 1, title: null, - ingress: null, + description: null, appearance: null, callToAction: null, blocks: [], @@ -168,12 +168,12 @@ SectionCarousel.propTypes = { defaultClasses: shape({ sectionDetails: string, title: string, - ingress: string, + description: string, ctaButton: string, }), numColumns: number, title: object, - ingress: object, + description: object, appearance: object, callToAction: object, blocks: arrayOf(object), diff --git a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js index 80884697e..250bc37da 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionColumns/SectionColumns.js @@ -34,7 +34,7 @@ const SectionColumns = props => { defaultClasses, numColumns, title, - ingress, + description, appearance, callToAction, blocks, @@ -47,7 +47,7 @@ const SectionColumns = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); + const hasHeaderFields = hasDataInFields([title, description, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; return ( @@ -61,7 +61,7 @@ const SectionColumns = props => { {hasHeaderFields ? ( <header className={defaultClasses.sectionDetails}> <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={description} className={defaultClasses.description} options={fieldOptions} /> <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> </header> ) : null} @@ -94,7 +94,7 @@ SectionColumns.defaultProps = { textClassName: null, numColumns: 1, title: null, - ingress: null, + description: null, appearance: null, callToAction: null, blocks: [], @@ -109,12 +109,12 @@ SectionColumns.propTypes = { defaultClasses: shape({ sectionDetails: string, title: string, - ingress: string, + description: string, ctaButton: string, }), numColumns: number, title: object, - ingress: object, + description: object, appearance: object, callToAction: object, blocks: arrayOf(object), diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js index bd7c702e6..0b879737f 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -21,7 +21,7 @@ const SectionFeatures = props => { rootClassName, defaultClasses, title, - ingress, + description, appearance, callToAction, blocks, @@ -34,7 +34,7 @@ const SectionFeatures = props => { const fieldComponents = options?.fieldComponents; const fieldOptions = { fieldComponents }; - const hasHeaderFields = hasDataInFields([title, ingress, callToAction], fieldOptions); + const hasHeaderFields = hasDataInFields([title, description, callToAction], fieldOptions); const hasBlocks = blocks?.length > 0; return ( @@ -48,7 +48,7 @@ const SectionFeatures = props => { {hasHeaderFields ? ( <header className={defaultClasses.sectionDetails}> <Field data={title} className={defaultClasses.title} options={fieldOptions} /> - <Field data={ingress} className={defaultClasses.ingress} options={fieldOptions} /> + <Field data={description} className={defaultClasses.description} options={fieldOptions} /> <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} /> </header> ) : null} @@ -80,7 +80,7 @@ SectionFeatures.defaultProps = { defaultClasses: null, textClassName: null, title: null, - ingress: null, + description: null, appearance: null, callToAction: null, blocks: [], @@ -95,11 +95,11 @@ SectionFeatures.propTypes = { defaultClasses: shape({ sectionDetails: string, title: string, - ingress: string, + description: string, ctaButton: string, }), title: object, - ingress: object, + description: object, appearance: object, callToAction: object, blocks: arrayOf(object), From 162bed864ce321f6ea35e4b3c09075d074576e09 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 6 Feb 2023 19:41:12 +0200 Subject: [PATCH 87/99] Prepare CMSPage, LandingPage, PrivacyPage, TermsOfServicePage for 'meta' All the 'meta' data comes from page asset file and it's handled inside PageBuilder. --- src/containers/CMSPage/CMSPage.js | 36 +------------ src/containers/LandingPage/FallbackPage.js | 18 +++++-- .../LandingPage/LandingPage.example.js | 16 +----- src/containers/LandingPage/LandingPage.js | 46 +---------------- .../__snapshots__/LandingPage.test.js.snap | 51 +------------------ src/containers/PageBuilder/README.md | 10 ++-- .../PrivacyPolicyPage/FallbackPage.js | 23 +++++---- .../PrivacyPolicyPage/PrivacyPolicyPage.js | 37 +------------- .../TermsOfServicePage/FallbackPage.js | 23 +++++---- .../TermsOfServicePage/TermsOfServicePage.js | 36 +------------ 10 files changed, 52 insertions(+), 244 deletions(-) diff --git a/src/containers/CMSPage/CMSPage.js b/src/containers/CMSPage/CMSPage.js index 55236b6c7..513bc7ff5 100644 --- a/src/containers/CMSPage/CMSPage.js +++ b/src/containers/CMSPage/CMSPage.js @@ -15,41 +15,7 @@ export const CMSPageComponent = props => { return <NotFoundPage />; } - // Schema for search engines (helps them to understand what this page is about) - // http://schema.org - // We are using JSON-LD format - - //////////////////////////////////////////////////////////////// - // TODO title and description should come from hosted assets. // - //////////////////////////////////////////////////////////////// - - // schemaTitle is used for <title> tag in addition to page schema for SEO - const schemaTitle = 'CMS page'; - // schemaDescription is used for different <meta> tags in addition to page schema for SEO - const schemaDescription = 'CMS page'; - const openGraphContentType = 'website'; - - // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas - // Read more about schema - // - https://schema.org/ - // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data - const pageSchemaForSEO = { - '@context': 'http://schema.org', - '@type': 'WebPage', - description: schemaDescription, - name: schemaTitle, - }; - - return ( - <PageBuilder - pageAssetsData={pageAssetsData?.[pageId]?.data} - title={schemaTitle} - description={schemaDescription} - schema={pageSchemaForSEO} - contentType={openGraphContentType} - inProgress={inProgress} - /> - ); + return <PageBuilder pageAssetsData={pageAssetsData?.[pageId]?.data} inProgress={inProgress} />; }; CMSPageComponent.propTypes = { diff --git a/src/containers/LandingPage/FallbackPage.js b/src/containers/LandingPage/FallbackPage.js index 1eefa01fd..0e5fa509e 100644 --- a/src/containers/LandingPage/FallbackPage.js +++ b/src/containers/LandingPage/FallbackPage.js @@ -10,8 +10,20 @@ export const fallbackSections = { sectionId: 'maintenance-mode', }, ], + meta: { + pageTitle: { + fieldType: 'metaTitle', + content: 'Home page', + }, + pageDescription: { + fieldType: 'metaDescription', + content: 'Home page fetch failed', + }, + }, }; +// Note: this microcopy/translation does not come from translation file. +// It needs to be something that is not part of fetched assets but built-in text const SectionMaintenanceMode = props => { const { sectionId } = props; @@ -30,7 +42,6 @@ const SectionMaintenanceMode = props => { // This is the fallback page, in case there's no Landing Page asset defined in Console. const FallbackPage = props => { - const { title, description, schema, contentType } = props; return ( <PageBuilder pageAssetsData={fallbackSections} @@ -39,10 +50,7 @@ const FallbackPage = props => { customMaintenance: { component: SectionMaintenanceMode }, }, }} - title={title} - description={description} - schema={schema} - contentType={contentType} + {...props} /> ); }; diff --git a/src/containers/LandingPage/LandingPage.example.js b/src/containers/LandingPage/LandingPage.example.js index 606f74616..05042cd69 100644 --- a/src/containers/LandingPage/LandingPage.example.js +++ b/src/containers/LandingPage/LandingPage.example.js @@ -1,21 +1,7 @@ import React from 'react'; import FallbackPage from './FallbackPage.js'; -const pageSchemaForSEO = { - '@context': 'http://schema.org', - '@type': 'WebPage', - description: 'schemaDescription', - name: 'schemaTitle', -}; - -const FallbackPageComponent = () => ( - <FallbackPage - title="title" - description="description" - pageSchemaForSEO={pageSchemaForSEO} - openGraphContentType="website" - /> -); +const FallbackPageComponent = () => <FallbackPage />; export const FallbackPageExample = { component: FallbackPageComponent, diff --git a/src/containers/LandingPage/LandingPage.js b/src/containers/LandingPage/LandingPage.js index e6fd373f3..857a239ec 100644 --- a/src/containers/LandingPage/LandingPage.js +++ b/src/containers/LandingPage/LandingPage.js @@ -4,64 +4,22 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import config from '../../config'; import { injectIntl, intlShape } from '../../util/reactIntl'; import { camelize } from '../../util/string'; import PageBuilder from '../../containers/PageBuilder/PageBuilder'; -import facebookImage from '../../assets/saunatimeFacebook-1200x630.jpg'; -import twitterImage from '../../assets/saunatimeTwitter-600x314.jpg'; - import FallbackPage from './FallbackPage'; import { ASSET_NAME } from './LandingPage.duck'; export const LandingPageComponent = props => { - const { intl, pageAssetsData, inProgress } = props; - - // Schema for search engines (helps them to understand what this page is about) - // http://schema.org - // We are using JSON-LD format - const siteTitle = config.siteTitle; - // schemaTitle is used for <title> tag in addition to page schema for SEO - const schemaTitle = intl.formatMessage({ id: 'LandingPage.schemaTitle' }, { siteTitle }); - // schemaDescription is used for different <meta> tags in addition to page schema for SEO - const schemaDescription = intl.formatMessage({ id: 'LandingPage.schemaDescription' }); - const schemaImage = `${config.canonicalRootURL}${facebookImage}`; - const openGraphContentType = 'website'; - - // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas - // Read more about schema - // - https://schema.org/ - // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data - const pageSchemaForSEO = { - '@context': 'http://schema.org', - '@type': 'WebPage', - description: schemaDescription, - name: schemaTitle, - image: [schemaImage], - }; + const { pageAssetsData, inProgress } = props; return ( <PageBuilder pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} - title={schemaTitle} - description={schemaDescription} - schema={pageSchemaForSEO} - contentType={openGraphContentType} inProgress={inProgress} - fallbackPage={ - <FallbackPage - title={schemaTitle} - description={schemaDescription} - schema={pageSchemaForSEO} - contentType={openGraphContentType} - /> - } - facebookImages={[{ url: facebookImage, width: 1200, height: 630 }]} - twitterImages={[ - { url: `${config.canonicalRootURL}${twitterImage}`, width: 600, height: 314 }, - ]} + fallbackPage={<FallbackPage />} /> ); }; diff --git a/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap b/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap index 9b44309e5..d9c1e74e9 100644 --- a/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap +++ b/src/containers/LandingPage/__snapshots__/LandingPage.test.js.snap @@ -2,55 +2,6 @@ exports[`LandingPage matches snapshot 1`] = ` <PageBuilder - contentType="website" - description="LandingPage.schemaDescription" - facebookImages={ - Array [ - Object { - "height": 630, - "url": "saunatimeFacebook-1200x630.jpg", - "width": 1200, - }, - ] - } - fallbackPage={ - <FallbackPage - contentType="website" - description="LandingPage.schemaDescription" - schema={ - Object { - "@context": "http://schema.org", - "@type": "WebPage", - "description": "LandingPage.schemaDescription", - "image": Array [ - "http://localhost:3000saunatimeFacebook-1200x630.jpg", - ], - "name": "LandingPage.schemaTitle", - } - } - title="LandingPage.schemaTitle" - /> - } - schema={ - Object { - "@context": "http://schema.org", - "@type": "WebPage", - "description": "LandingPage.schemaDescription", - "image": Array [ - "http://localhost:3000saunatimeFacebook-1200x630.jpg", - ], - "name": "LandingPage.schemaTitle", - } - } - title="LandingPage.schemaTitle" - twitterImages={ - Array [ - Object { - "height": 314, - "url": "http://localhost:3000saunatimeTwitter-600x314.jpg", - "width": 600, - }, - ] - } + fallbackPage={<FallbackPage />} /> `; diff --git a/src/containers/PageBuilder/README.md b/src/containers/PageBuilder/README.md index 23d3e09f1..b1e4bfbf7 100644 --- a/src/containers/PageBuilder/README.md +++ b/src/containers/PageBuilder/README.md @@ -76,6 +76,12 @@ don't get content through the Asset Delivery API. ], }, ], + meta: { + pageTitle: { + fieldType: 'metaTitle', + content: 'My Custom Page', + }, + }, }} options={{ sectionComponents: { @@ -93,9 +99,5 @@ don't get content through the Asset Delivery API. }, }, }} - contentType={openGraphContentType} - description={description} - title={title} - schema={pageSchemaForSEO} /> ``` diff --git a/src/containers/PrivacyPolicyPage/FallbackPage.js b/src/containers/PrivacyPolicyPage/FallbackPage.js index 26ebba293..f72a3eb04 100644 --- a/src/containers/PrivacyPolicyPage/FallbackPage.js +++ b/src/containers/PrivacyPolicyPage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'privacy', - appearance: { fieldType: 'customAppearance', color: '#ffffff' }, + appearance: { fieldType: 'customAppearance', backgroundColor: '#ffffff' }, title: { fieldType: 'heading1', content: 'Privacy Policy' }, blocks: [ { @@ -32,20 +32,21 @@ export const fallbackSections = { ], }, ], + meta: { + pageTitle: { + fieldType: 'metaTitle', + content: 'Privacy policy page', + }, + pageDescription: { + fieldType: 'metaDescription', + content: 'Privacy policy fetch failed', + }, + }, }; // This is the fallback page, in case there's no Privacy Policy asset defined in Console. const FallbackPage = props => { - const { title, description, schema, contentType } = props; - return ( - <PageBuilder - pageAssetsData={fallbackSections} - title={title} - description={description} - schema={schema} - contentType={contentType} - /> - ); + return <PageBuilder pageAssetsData={fallbackSections} {...props} />; }; export default FallbackPage; diff --git a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js index ed7d3f667..85b6a0568 100644 --- a/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js +++ b/src/containers/PrivacyPolicyPage/PrivacyPolicyPage.js @@ -4,7 +4,6 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import config from '../../config'; import { injectIntl, intlShape } from '../../util/reactIntl'; import { camelize } from '../../util/string'; @@ -48,45 +47,13 @@ const PrivacyPolicyContent = props => { // Presentational component for PrivacyPolicyPage const PrivacyPolicyPageComponent = props => { - const { intl, pageAssetsData, inProgress } = props; - - // Schema for search engines (helps them to understand what this page is about) - // http://schema.org - // We are using JSON-LD format - const siteTitle = config.siteTitle; - // schemaTitle is used for <title> tag in addition to page schema for SEO - const schemaTitle = intl.formatMessage({ id: 'PrivacyPolicyPage.schemaTitle' }, { siteTitle }); - // schemaDescription is used for different <meta> tags in addition to page schema for SEO - const schemaDescription = intl.formatMessage({ id: 'PrivacyPolicyPage.schemaDescription' }); - const openGraphContentType = 'website'; - - // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas - // Read more about schema - // - https://schema.org/ - // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data - const pageSchemaForSEO = { - '@context': 'http://schema.org', - '@type': 'WebPage', - description: schemaDescription, - name: schemaTitle, - }; + const { pageAssetsData, inProgress } = props; return ( <PageBuilder pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} - title={schemaTitle} - description={schemaDescription} - schema={pageSchemaForSEO} - contentType={openGraphContentType} inProgress={inProgress} - fallbackPage={ - <FallbackPage - title={schemaTitle} - description={schemaDescription} - schema={pageSchemaForSEO} - contentType={openGraphContentType} - /> - } + fallbackPage={<FallbackPage />} /> ); }; diff --git a/src/containers/TermsOfServicePage/FallbackPage.js b/src/containers/TermsOfServicePage/FallbackPage.js index 0e74f86b3..9226a9843 100644 --- a/src/containers/TermsOfServicePage/FallbackPage.js +++ b/src/containers/TermsOfServicePage/FallbackPage.js @@ -18,7 +18,7 @@ export const fallbackSections = { { sectionType: 'article', sectionId: 'terms', - appearance: { fieldType: 'customAppearance', color: '#ffffff' }, + appearance: { fieldType: 'customAppearance', backgroundColor: '#ffffff' }, title: { fieldType: 'heading1', content: 'Terms of Service' }, blocks: [ { @@ -32,20 +32,21 @@ export const fallbackSections = { ], }, ], + meta: { + pageTitle: { + fieldType: 'metaTitle', + content: 'Terms of service page', + }, + pageDescription: { + fieldType: 'metaDescription', + content: 'Terms of service fetch failed', + }, + }, }; // This is the fallback page, in case there's no Terms of Service asset defined in Console. const FallbackPage = props => { - const { title, description, schema, contentType } = props; - return ( - <PageBuilder - pageAssetsData={fallbackSections} - title={title} - description={description} - schema={schema} - contentType={contentType} - /> - ); + return <PageBuilder pageAssetsData={fallbackSections} {...props} />; }; export default FallbackPage; diff --git a/src/containers/TermsOfServicePage/TermsOfServicePage.js b/src/containers/TermsOfServicePage/TermsOfServicePage.js index b812243d7..56817ac50 100644 --- a/src/containers/TermsOfServicePage/TermsOfServicePage.js +++ b/src/containers/TermsOfServicePage/TermsOfServicePage.js @@ -48,45 +48,13 @@ const TermsOfServiceContent = props => { // Presentational component for TermsOfServicePage const TermsOfServicePageComponent = props => { - const { intl, pageAssetsData, inProgress } = props; - - // Schema for search engines (helps them to understand what this page is about) - // http://schema.org - // We are using JSON-LD format - const siteTitle = config.siteTitle; - // schemaTitle is used for <title> tag in addition to page schema for SEO - const schemaTitle = intl.formatMessage({ id: 'TermsOfServicePage.schemaTitle' }, { siteTitle }); - // schemaDescription is used for different <meta> tags in addition to page schema for SEO - const schemaDescription = intl.formatMessage({ id: 'TermsOfServicePage.schemaDescription' }); - const openGraphContentType = 'website'; - - // In addition to this schema for search engines, src/components/Page/Page.js adds some extra schemas - // Read more about schema - // - https://schema.org/ - // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data - const pageSchemaForSEO = { - '@context': 'http://schema.org', - '@type': 'WebPage', - description: schemaDescription, - name: schemaTitle, - }; + const { pageAssetsData, inProgress } = props; return ( <PageBuilder pageAssetsData={pageAssetsData?.[camelize(ASSET_NAME)]?.data} - title={schemaTitle} - description={schemaDescription} - schema={pageSchemaForSEO} - contentType={openGraphContentType} inProgress={inProgress} - fallbackPage={ - <FallbackPage - title={schemaTitle} - description={schemaDescription} - schema={pageSchemaForSEO} - contentType={openGraphContentType} - /> - } + fallbackPage={<FallbackPage />} /> ); }; From bdce95dc9e8c8e45ed7665be320ca7c4e833dff4 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 6 Feb 2023 19:45:54 +0200 Subject: [PATCH 88/99] Page: rename contentType as openGraphType --- src/components/Page/Page.js | 8 ++++---- src/containers/ListingPage/ListingPage.js | 1 - .../ListingPage/__snapshots__/ListingPage.test.js.snap | 1 - src/util/seo.js | 8 ++++---- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/Page/Page.js b/src/components/Page/Page.js index 5a417091b..93afba57c 100644 --- a/src/components/Page/Page.js +++ b/src/components/Page/Page.js @@ -78,7 +78,7 @@ class PageComponent extends Component { scrollingDisabled, referrer, author, - contentType, + openGraphType, description, facebookImages, published, @@ -126,8 +126,8 @@ class PageComponent extends Component { const metaToHead = metaTagProps({ author, - contentType, description: metaDescription, + openGraphType, facebookImages: facebookImgs, twitterImages: twitterImgs, published, @@ -231,7 +231,7 @@ PageComponent.defaultProps = { rootClassName: null, children: null, author: null, - contentType: 'website', + openGraphType: 'website', description: null, facebookImages: null, twitterImages: null, @@ -254,7 +254,7 @@ PageComponent.propTypes = { // SEO related props author: string, - contentType: string, // og:type + openGraphType: string, // og:type description: string, // page description facebookImages: arrayOf( shape({ diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 216adefc7..61aead7d0 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -391,7 +391,6 @@ export class ListingPageComponent extends Component { title={schemaTitle} scrollingDisabled={scrollingDisabled} author={authorDisplayName} - contentType="website" description={description} facebookImages={facebookImages} twitterImages={twitterImages} diff --git a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap index 70a6a44da..be0caec92 100644 --- a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap +++ b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap @@ -3,7 +3,6 @@ exports[`ListingPage matches snapshot 1`] = ` <Page author="user-1 display name" - contentType="website" description="listing1 description" facebookImages={Array []} schema={ diff --git a/src/util/seo.js b/src/util/seo.js index bf43776ae..997204e32 100644 --- a/src/util/seo.js +++ b/src/util/seo.js @@ -15,8 +15,8 @@ const ensureOpenGraphLocale = locale => { export const openGraphMetaProps = data => { const { canonicalRootURL, - contentType, description, + openGraphType, facebookAppId, facebookImages, locale, @@ -28,12 +28,12 @@ export const openGraphMetaProps = data => { url, } = data; - if (!(title && description && contentType && url && facebookImages && canonicalRootURL)) { + if (!(title && description && openGraphType && url && facebookImages && canonicalRootURL)) { /* eslint-disable no-console */ if (console && console.warn) { console.warn( `Can't create Open Graph meta tags: - title, description, contentType, url, facebookImages, and canonicalRootURL are needed.` + title, description, openGraphType, url, facebookImages, and canonicalRootURL are needed.` ); } /* eslint-enable no-console */ @@ -43,7 +43,7 @@ export const openGraphMetaProps = data => { const openGraphMeta = [ { property: 'og:description', content: description }, { property: 'og:title', content: title }, - { property: 'og:type', content: contentType }, + { property: 'og:type', content: openGraphType }, { property: 'og:url', content: url }, { property: 'og:locale', content: ensureOpenGraphLocale(locale) }, ]; From be8d4813b484f308ef7a846b1f3e5390cd2b9048 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 6 Feb 2023 19:51:27 +0200 Subject: [PATCH 89/99] Page: tiny refactoring --- src/components/Page/Page.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/Page/Page.js b/src/components/Page/Page.js index 93afba57c..57fcb3ca7 100644 --- a/src/components/Page/Page.js +++ b/src/components/Page/Page.js @@ -95,7 +95,6 @@ class PageComponent extends Component { }); this.scrollingDisabledChanged(scrollingDisabled); - const referrerMeta = referrer ? <meta name="referrer" content={referrer} /> : null; const canonicalRootURL = config.canonicalRootURL; const shouldReturnPathOnly = referrer && referrer !== 'unsafe-url'; @@ -139,9 +138,6 @@ class PageComponent extends Component { locale: intl.locale, }); - // eslint-disable-next-line react/no-array-index-key - const metaTags = metaToHead.map((metaProps, i) => <meta key={i} {...metaProps} />); - const facebookPage = config.siteFacebookPage; const twitterPage = twitterPageURL(config.siteTwitterHandle); const instagramPage = config.siteInstagramPage; @@ -200,11 +196,13 @@ class PageComponent extends Component { }} > <title>{title} - {referrerMeta} + {referrer ? : null} - {metaTags} + {metaToHead.map((metaProps, i) => ( + + ))} From 9a8accc4a5bbe546001d80950517f41c66fe41c6 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 6 Feb 2023 19:47:30 +0200 Subject: [PATCH 90/99] util/data.js: omit exposed image resolver configs --- src/util/data.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/util/data.js b/src/util/data.js index 0f564cd06..8ab11f3c7 100644 --- a/src/util/data.js +++ b/src/util/data.js @@ -173,9 +173,18 @@ const denormalizeJsonData = (data, included) => { value._ref && value._ref?.type === 'imageAsset' && value._ref?.id; + // If there is no image included, + // the _ref might contain parameters for image resolver (Asset Delivery API resolves image URLs on the fly) + const hasUnresolvedImageRef = + typeof value == 'object' && value._ref && value._ref?.resolver === 'image'; + if (hasImageRefAsValue) { const foundRef = included.find(inc => inc.id === value._ref?.id); copy[key] = foundRef; + } else if (hasUnresolvedImageRef) { + // Don't add faulty image ref + // Note: At the time of writing, assets can expose resolver configs, + // which we don't want to deal with. } else { copy[key] = denormalizeJsonData(value, included); } From 2cd1eeabcdc81f77beac53cabdfbeb8667530bb1 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 6 Feb 2023 19:49:22 +0200 Subject: [PATCH 91/99] util/seo.js: OpenGraph and Twitter should use socialSharingTitle and socialSharingDescription --- src/util/seo.js | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/util/seo.js b/src/util/seo.js index 997204e32..d86738f97 100644 --- a/src/util/seo.js +++ b/src/util/seo.js @@ -15,7 +15,6 @@ const ensureOpenGraphLocale = locale => { export const openGraphMetaProps = data => { const { canonicalRootURL, - description, openGraphType, facebookAppId, facebookImages, @@ -23,17 +22,27 @@ export const openGraphMetaProps = data => { published, siteTitle, tags, - title, + socialSharingTitle, + socialSharingDescription, updated, url, } = data; - if (!(title && description && openGraphType && url && facebookImages && canonicalRootURL)) { + if ( + !( + socialSharingTitle && + socialSharingDescription && + openGraphType && + url && + facebookImages && + canonicalRootURL + ) + ) { /* eslint-disable no-console */ if (console && console.warn) { console.warn( `Can't create Open Graph meta tags: - title, description, openGraphType, url, facebookImages, and canonicalRootURL are needed.` + socialSharingTitle, socialSharingDescription, openGraphType, url, facebookImages, and canonicalRootURL are needed.` ); } /* eslint-enable no-console */ @@ -41,8 +50,8 @@ export const openGraphMetaProps = data => { } const openGraphMeta = [ - { property: 'og:description', content: description }, - { property: 'og:title', content: title }, + { property: 'og:description', content: socialSharingDescription }, + { property: 'og:title', content: socialSharingTitle }, { property: 'og:type', content: openGraphType }, { property: 'og:url', content: url }, { property: 'og:locale', content: ensureOpenGraphLocale(locale) }, @@ -91,20 +100,20 @@ export const openGraphMetaProps = data => { export const twitterMetaProps = data => { const { canonicalRootURL, - description, siteTwitterHandle, - title, + socialSharingTitle, + socialSharingDescription, twitterHandle, twitterImages, url, } = data; - if (!(title && description && siteTwitterHandle && url)) { + if (!(socialSharingTitle && socialSharingDescription && siteTwitterHandle && url)) { /* eslint-disable no-console */ if (console && console.warn) { console.warn( `Can't create twitter card meta tags: - title, description, siteTwitterHandle, and url are needed.` + socialSharingTitle, socialSharingDescription, siteTwitterHandle, and url are needed.` ); } /* eslint-enable no-console */ @@ -113,8 +122,8 @@ export const twitterMetaProps = data => { const twitterMeta = [ { name: 'twitter:card', content: 'summary_large_image' }, - { name: 'twitter:title', content: title }, - { name: 'twitter:description', content: description }, + { name: 'twitter:title', content: socialSharingTitle }, + { name: 'twitter:description', content: socialSharingDescription }, { name: 'twitter:site', content: siteTwitterHandle }, { name: 'twitter:url', content: url }, ]; From 6cf6aaee112d3896c1e936855fedf079e50c2798 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 6 Feb 2023 20:09:14 +0200 Subject: [PATCH 92/99] Page: add prop: socialSharing --- src/components/Page/Page.js | 49 +++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/components/Page/Page.js b/src/components/Page/Page.js index 57fcb3ca7..7784ce318 100644 --- a/src/components/Page/Page.js +++ b/src/components/Page/Page.js @@ -83,6 +83,7 @@ class PageComponent extends Component { facebookImages, published, schema, + socialSharing, tags, title, twitterHandle, @@ -104,9 +105,17 @@ class PageComponent extends Component { const siteTitle = config.siteTitle; const schemaTitle = intl.formatMessage({ id: 'Page.schemaTitle' }, { siteTitle }); const schemaDescription = intl.formatMessage({ id: 'Page.schemaDescription' }); - const metaTitle = title || schemaTitle; - const metaDescription = description || schemaDescription; - const facebookImgs = facebookImages || [ + const pageTitle = title || schemaTitle; + const pageDescription = description || schemaDescription; + const { + title: socialSharingTitle, + description: socialSharingDescription, + images1200: socialSharingImages1200, + // Note: we use image with open graph's aspect ratio (1.91:1) also with Twitter + images600: socialSharingImages600, + } = socialSharing || {}; + + const openGraphFallbackImages = [ { name: 'facebook', url: `${canonicalRootURL}${facebookImage}`, @@ -114,7 +123,7 @@ class PageComponent extends Component { height: 630, }, ]; - const twitterImgs = twitterImages || [ + const twitterFallbackImages = [ { name: 'twitter', url: `${canonicalRootURL}${twitterImage}`, @@ -122,16 +131,19 @@ class PageComponent extends Component { height: 314, }, ]; + const facebookImgs = socialSharingImages1200 || facebookImages || openGraphFallbackImages; + const twitterImgs = socialSharingImages600 || twitterImages || twitterFallbackImages; const metaToHead = metaTagProps({ author, - description: metaDescription, openGraphType, + socialSharingTitle: socialSharingTitle || pageTitle, + socialSharingDescription: socialSharingDescription || pageDescription, + description: pageDescription, facebookImages: facebookImgs, twitterImages: twitterImgs, published, tags, - title: metaTitle, twitterHandle, updated, url: canonicalUrl, @@ -195,7 +207,7 @@ class PageComponent extends Component { lang: intl.locale, }} > - {title} + {pageTitle} {referrer ? : null} @@ -236,6 +248,7 @@ PageComponent.defaultProps = { published: null, referrer: null, schema: null, + socialSharing: null, tags: null, twitterHandle: null, updated: null, @@ -270,8 +283,28 @@ PageComponent.propTypes = { ), published: string, // article:published_time schema: oneOfType([object, array]), // http://schema.org + socialSharing: shape({ + title: string, + description: string, + images1200: arrayOf( + // Page asset file can define this + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ), + images600: arrayOf( + // Page asset file can define this + shape({ + width: number.isRequired, + height: number.isRequired, + url: string.isRequired, + }) + ), + }), tags: string, // article:tag - title: string.isRequired, // page title + title: string, // page title twitterHandle: string, // twitter handle updated: string, // article:modified_time From d2ebb98ac846a8609c43bfba5723a83dab2dd399 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 6 Feb 2023 20:16:17 +0200 Subject: [PATCH 93/99] Field: handle field types in 'meta' data --- .../PageBuilder/Field/Field.helpers.js | 86 +++++++++++-------- .../PageBuilder/Field/Field.helpers.test.js | 61 +++++++++++++ src/containers/PageBuilder/Field/Field.js | 9 ++ 3 files changed, 120 insertions(+), 36 deletions(-) diff --git a/src/containers/PageBuilder/Field/Field.helpers.js b/src/containers/PageBuilder/Field/Field.helpers.js index b11f878bc..3d94125de 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.js +++ b/src/containers/PageBuilder/Field/Field.helpers.js @@ -41,6 +41,28 @@ export const exposeLinkProps = data => { return cleanUrl ? { children: linkText, href: cleanUrl } : {}; }; +const getValidSanitizedImage = image => { + const { id, type, attributes } = image || {}; + const variantEntries = Object.entries(attributes?.variants || {}); + const variants = variantEntries.reduce((validVariants, entry) => { + const [key, value] = entry; + const { url, width, height } = value || {}; + + const isValid = typeof width === 'number' && typeof height === 'number'; + return isValid + ? { + ...validVariants, + [key]: { url: sanitizeUrl(url), width, height }, + } + : validVariants; + }, {}); + + const isValidImage = Object.keys(variants).length > 0; + const sanitizedImage = { id, type, attributes: { ...attributes, variants } }; + + return isValidImage ? sanitizedImage : null; +}; + /** * Exposes "alt" and image props. * The "image" contains imageAsset entity, which has been denormalized at this point: @@ -70,30 +92,15 @@ export const exposeImageProps = data => { // Note: data includes also "aspectRatio" key (and "fieldType"), // but image refs can rely on actual image variants const { alt, image } = data; - const { id, type, attributes } = image || {}; + const { type } = image || {}; if (type !== 'imageAsset') { return {}; } - const variantEntries = Object.entries(image?.attributes?.variants || {}); - const variants = variantEntries.reduce((validVariants, entry) => { - const [key, value] = entry; - const { url, width, height } = value || {}; - - const isValid = typeof width === 'number' && typeof height === 'number'; - return isValid - ? { - ...validVariants, - [key]: { url: sanitizeUrl(url), width, height }, - } - : validVariants; - }, {}); - const alternativeText = typeof alt === 'string' ? alt : '🖼️'; - const isValidImage = Object.keys(variants).length > 0; - const sanitizedImage = { id, type, attributes: { ...attributes, variants } }; - return isValidImage ? { alt: alternativeText, image: sanitizedImage } : {}; + const sanitizedImage = getValidSanitizedImage(image); + return sanitizedImage ? { alt: alternativeText, image: sanitizedImage } : {}; }; /** @@ -118,7 +125,7 @@ const exposeColorValue = color => { */ export const exposeCustomAppearanceProps = data => { const { backgroundImage, backgroundColor, textColor, alt } = data; - const { id, type, attributes } = backgroundImage || {}; + const { type } = backgroundImage || {}; if (!!type && type !== 'imageAsset') { return {}; @@ -132,23 +139,8 @@ export const exposeCustomAppearanceProps = data => { const isValidTextColor = ['light', 'dark'].includes(textColor); const textColorMaybe = isValidTextColor ? { textColor } : {}; - const variantEntries = Object.entries(backgroundImage?.attributes?.variants || {}); - const variants = variantEntries.reduce((validVariants, entry) => { - const [key, value] = entry; - const { url, width, height } = value || {}; - - const isValid = typeof width === 'number' && typeof height === 'number'; - return isValid - ? { - ...validVariants, - [key]: { url: sanitizeUrl(url), width, height }, - } - : validVariants; - }, {}); - - const isValidImage = Object.keys(variants).length > 0; - const sanitizedImage = { id, type, attributes: { ...attributes, variants } }; - const backgroundImageMaybe = isValidImage ? { backgroundImage: sanitizedImage, alt } : {}; + const sanitizedImage = getValidSanitizedImage(backgroundImage); + const backgroundImageMaybe = sanitizedImage ? { backgroundImage: sanitizedImage, alt } : {}; return { ...backgroundImageMaybe, @@ -184,3 +176,25 @@ export const exposeYoutubeProps = data => { } : {}; }; + +export const exposeOpenGraphData = data => { + const { title, description, image } = data || {}; + const { type } = image || {}; + + if (!!type && type !== 'imageAsset') { + return {}; + } + + const isString = content => typeof content === 'string' && content.length > 0; + const sanitizedImage = getValidSanitizedImage(image); + const image1200 = sanitizedImage?.attributes?.variants?.social1200; + const image600 = sanitizedImage?.attributes?.variants?.social600; + + return { + title: isString(title) ? title : null, + description: isString(description) ? description : null, + // Open Graph can handle multiple images, so we return arrays for the sake of consistency + images1200: image1200 ? [image1200] : null, + images600: image600 ? [image600] : null, + }; +}; diff --git a/src/containers/PageBuilder/Field/Field.helpers.test.js b/src/containers/PageBuilder/Field/Field.helpers.test.js index 71d4e07a2..930a28aba 100644 --- a/src/containers/PageBuilder/Field/Field.helpers.test.js +++ b/src/containers/PageBuilder/Field/Field.helpers.test.js @@ -6,6 +6,7 @@ import { exposeImageProps, exposeCustomAppearanceProps, exposeYoutubeProps, + exposeOpenGraphData, } from './Field.helpers'; describe('Field helpers', () => { @@ -317,4 +318,64 @@ describe('Field helpers', () => { expect(exposeYoutubeProps({ youtubeVideoId: '9RQli>kX4vvw' })).toEqual({}); }); }); + + describe('exposeOpenGraphData(data)', () => { + it('should return title, description, images1200, and images600 props ', () => { + const title = 'Title'; + const description = 'Description'; + const imageVariant1200 = { + url: `https://picsum.photos/1200/630`, + width: 1200, + height: 630, + }; + const imageVariant600 = { + url: `https://picsum.photos/600/315`, + width: 600, + height: 315, + }; + + const image = { + id: 'image', + type: 'imageAsset', + attributes: { + variants: { + social1200: imageVariant1200, + social600: imageVariant600, + }, + }, + }; + + const data = { title, description, image }; + expect(exposeOpenGraphData(data)).toEqual({ + title, + description, + images1200: [imageVariant1200], + images600: [imageVariant600], + }); + expect(exposeOpenGraphData({ title })).toEqual({ + title, + description: null, + images1200: null, + images600: null, + }); + expect(exposeOpenGraphData({ description })).toEqual({ + title: null, + description, + images1200: null, + images600: null, + }); + expect(exposeOpenGraphData()).toEqual({ + title: null, + description: null, + images1200: null, + images600: null, + }); + expect(exposeOpenGraphData({ unKnownKey: null })).toEqual({ + title: null, + description: null, + images1200: null, + images600: null, + }); + }); + }); }); diff --git a/src/containers/PageBuilder/Field/Field.js b/src/containers/PageBuilder/Field/Field.js index c16d68bc8..3d0d087e1 100644 --- a/src/containers/PageBuilder/Field/Field.js +++ b/src/containers/PageBuilder/Field/Field.js @@ -24,6 +24,7 @@ import { exposeCustomAppearanceProps, exposeImageProps, exposeYoutubeProps, + exposeOpenGraphData, } from './Field.helpers'; const TEXT_CONTENT = [ @@ -35,6 +36,8 @@ const TEXT_CONTENT = [ 'heading6', 'paragraph', 'markdown', + 'metaTitle', + 'metaDescription', ]; //////////////////////// @@ -97,6 +100,12 @@ const defaultFieldComponents = { }, }, }, + + // Page's metadata goes to and it is not currently rendered as a separate component + // Instead, valid data is passed to , which then renders it using react-helmet-async + metaTitle: { component: null, pickValidProps: exposeContentString }, + metaDescription: { component: null, pickValidProps: exposeContentString }, + openGraphData: { component: null, pickValidProps: exposeOpenGraphData }, }; ////////////////// From 77d2348edec7ef7d67e813475a5a43f6b54d0062 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 6 Feb 2023 20:18:25 +0200 Subject: [PATCH 94/99] PageBuilder: extract field data inside 'meta' object of page asset --- src/containers/CMSPage/CMSPage.js | 8 +++- src/containers/PageBuilder/PageBuilder.js | 54 ++++++++++++++++++++--- src/containers/PageBuilder/README.md | 9 ++-- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/containers/CMSPage/CMSPage.js b/src/containers/CMSPage/CMSPage.js index 513bc7ff5..63da3698f 100644 --- a/src/containers/CMSPage/CMSPage.js +++ b/src/containers/CMSPage/CMSPage.js @@ -15,7 +15,13 @@ export const CMSPageComponent = props => { return ; } - return ; + return ( + + ); }; CMSPageComponent.propTypes = { diff --git a/src/containers/PageBuilder/PageBuilder.js b/src/containers/PageBuilder/PageBuilder.js index 69b1c8b1c..0acf02e93 100644 --- a/src/containers/PageBuilder/PageBuilder.js +++ b/src/containers/PageBuilder/PageBuilder.js @@ -3,12 +3,53 @@ import React from 'react'; import { Footer as FooterContent } from '../../components/index.js'; import { TopbarContainer } from '../../containers/index.js'; +import { validProps } from './Field'; + import LayoutComposer from './LayoutComposer/index.js'; import SectionBuilder from './SectionBuilder/SectionBuilder.js'; import StaticPage from './StaticPage.js'; import css from './PageBuilder.module.css'; +const getMetadata = (meta, schemaType, fieldOptions) => { + const { pageTitle, pageDescription, socialSharing } = meta; + + // pageTitle is used for tag in addition to page schema for SEO + const title = validProps(pageTitle, fieldOptions)?.content; + // pageDescription is used for different <meta> tags in addition to page schema for SEO + const description = validProps(pageDescription, fieldOptions)?.content; + // Data used when the page is shared in social media services + const openGraph = validProps(socialSharing, fieldOptions); + // We add OpenGraph image as schema image if it exists. + const schemaImage = openGraph?.images1200?.[0]?.url; + const schemaImageMaybe = schemaImage ? { image: [schemaImage] } : {}; + const isArticle = ['Article', 'NewsArticle', 'TechArticle'].includes(schemaType); + const schemaHeadlineMaybe = isArticle ? { headline: title } : {}; + + // Schema for search engines (helps them to understand what this page is about) + // http://schema.org (This template uses JSON-LD format) + // + // In addition to this schema data for search engines, src/components/Page/Page.js adds some extra schemas + // Read more about schema: + // - https://schema.org/ + // - https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data + const pageSchemaForSEO = { + '@context': 'http://schema.org', + '@type': schemaType || 'WebPage', + description: description, + name: title, + ...schemaHeadlineMaybe, + ...schemaImageMaybe, + }; + + return { + title, + description, + schema: pageSchemaForSEO, + socialSharing: openGraph, + }; +}; + ////////////////// // Page Builder // ////////////////// @@ -32,14 +73,17 @@ import css from './PageBuilder.module.css'; * @returns page component */ const PageBuilder = props => { - const { pageAssetsData, inProgress, fallbackPage, options, ...pageProps } = props; + const { pageAssetsData, inProgress, fallbackPage, schemaType, options, ...pageProps } = props; if (!pageAssetsData && fallbackPage && !inProgress) { return fallbackPage; } - const data = pageAssetsData || {}; - const sectionsData = data?.sections || []; + // Page asset contains UI info and metadata related to it. + // - "sections" (data that goes inside <body>) + // - "meta" (which is data that goes inside <head>) + const { sections = [], meta = {} } = pageAssetsData || {}; + const pageMetaProps = getMetadata(meta, schemaType, options?.fieldComponents); const layoutAreas = ` topbar @@ -47,7 +91,7 @@ const PageBuilder = props => { footer `; return ( - <StaticPage {...pageProps}> + <StaticPage {...pageMetaProps} {...pageProps}> <LayoutComposer areas={layoutAreas} className={css.layout}> {props => { const { Topbar, Main, Footer } = props; @@ -57,7 +101,7 @@ const PageBuilder = props => { <TopbarContainer /> </Topbar> <Main as="main" className={css.main}> - <SectionBuilder sections={sectionsData} options={options} /> + <SectionBuilder sections={sections} options={options} /> </Main> <Footer> <FooterContent /> diff --git a/src/containers/PageBuilder/README.md b/src/containers/PageBuilder/README.md index b1e4bfbf7..d52fa786a 100644 --- a/src/containers/PageBuilder/README.md +++ b/src/containers/PageBuilder/README.md @@ -8,8 +8,8 @@ solution with headless CMS services, the schema of the page asset represents the modeling**. It defines what kind of data needs to be asked from a content writer. In Flex, content writing happens in Console, which means that content writers are marketplace operators. -The smallest piece of information in page asset is a field. It defines a piece of data and its fieldType. -For example: +The smallest piece of information in page asset is a field. It defines a piece of data and its +fieldType. For example: ```json "title": { @@ -21,8 +21,9 @@ For example: The default asset schema for page content has 3 levels that can include content fields: - **page asset** (data from Asset Delivery API) - - **sections** (page asset contains an array of sections) - - **blocks** (section might contain an array of blocks) + - **sections** (UI: an array of sections) + - **blocks** (UI: section might contain an array of blocks) + - _meta_ (metadata for the page aka data for `<head>` element) **PageBuilder** reads the page asset, and when it gets to the _sections_ array, it uses **SectionBuilder** to render its content. Similarly, SectionBuilder passes _blocks_ array to From a2e2697bdfb15dfd3861502cbd9e64a940e7f81e Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Mon, 6 Feb 2023 23:24:56 +0200 Subject: [PATCH 95/99] Page: publisher seemed to confuse Google's structured data --- src/components/Page/Page.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Page/Page.js b/src/components/Page/Page.js index 7784ce318..e2a079d07 100644 --- a/src/components/Page/Page.js +++ b/src/components/Page/Page.js @@ -162,7 +162,8 @@ class PageComponent extends Component { // Schema attribute can be either single schema object or an array of objects // This makes it possible to include several different items from the same page. // E.g. Product, Place, Video - const schemaFromProps = Array.isArray(schema) ? schema : [schema]; + const hasSchema = schema != null; + const schemaFromProps = hasSchema && Array.isArray(schema) ? schema : hasSchema ? [schema] : []; const schemaArrayJSONString = JSON.stringify([ ...schemaFromProps, { @@ -181,9 +182,6 @@ class PageComponent extends Component { url: canonicalRootURL, description: schemaDescription, name: schemaTitle, - publisher: { - '@id': `${canonicalRootURL}#organization`, - }, }, ]); From 61064aa8098380da06c9156163ce0cea43ac3d27 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Thu, 9 Feb 2023 15:01:56 +0200 Subject: [PATCH 96/99] SectionFeatures: a prop (responsiveImagesizes) was missing. --- .../SectionBuilder/SectionFeatures/SectionFeatures.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js index 0b879737f..4b35436c5 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionFeatures/SectionFeatures.js @@ -62,6 +62,7 @@ const SectionFeatures = props => { rootClassName={css.block} ctaButtonClass={defaultClasses.ctaButton} blocks={blocks} + responsiveImageSizes="(max-width: 767px) 100vw, 568px" options={options} /> </div> From 3b80e723ea086f7b2c572f7e45970cc61f1b4eaf Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Fri, 10 Feb 2023 13:07:20 +0200 Subject: [PATCH 97/99] CTA button in blocks: remove custom styling --- .../SectionBuilder/SectionArticle/SectionArticle.js | 6 +++++- .../SectionBuilder/SectionArticle/SectionArticle.module.css | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js index 7ed58e663..faed3b82b 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.js @@ -54,7 +54,11 @@ const SectionArticle = props => { [css.noSidePaddings]: isInsideContainer, })} > - <BlockBuilder blocks={blocks} ctaButtonClass={css.ctaButton} options={options} /> + <BlockBuilder + blocks={blocks} + ctaButtonClass={defaultClasses.ctaButton} + options={options} + /> </div> ) : null} </SectionContainer> diff --git a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css index 861bbb2a5..d67a45dbb 100644 --- a/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css +++ b/src/containers/PageBuilder/SectionBuilder/SectionArticle/SectionArticle.module.css @@ -12,7 +12,3 @@ padding-left: 0; padding-right: 0; } - -.ctaButton { - margin-top: 24px; -} From 10ed1dc703d80125b0272977e928b05a653525fb Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 14 Feb 2023 14:28:54 +0200 Subject: [PATCH 98/99] Udpate changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbd0ab12..8b632d80f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,20 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2022-XX-XX +- [add] This adds support for page asset files that can be created in Console. These asset files are + taken into use for + + - LandingPage + - TermsOfServicePage + - PrivacyPolicyPage + - AboutPage + - and other static pages can also be created through Console (they'll be visible in route: + /p/:asset-name/) + + [#1520](https://github.com/sharetribe/ftw-daily/pull/1520) + ## [v9.1.0] 2023-02-07 + - [change] Norway's stripe config should use NOK not EUR. [#1579](https://github.com/sharetribe/ftw-daily/pull/1579) - [delete] Update README.md after changes in [#1555]. From 9469668888d1d870a959aee2ffa8adfada45caa5 Mon Sep 17 00:00:00 2001 From: Vesa Luusua <vesa.luusua@gmail.com> Date: Tue, 14 Feb 2023 15:02:20 +0200 Subject: [PATCH 99/99] New Release: v10.0.0 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b632d80f..67193613b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2022-XX-XX +## [v10.0.0] 2023-02-14 + - [add] This adds support for page asset files that can be created in Console. These asset files are taken into use for @@ -26,6 +28,8 @@ way to update this template, but currently, we follow a pattern: [#1520](https://github.com/sharetribe/ftw-daily/pull/1520) + [v10.0.0]: https://github.com/sharetribe/ftw-daily/compare/v9.1.0.../v10.0.0 + ## [v9.1.0] 2023-02-07 - [change] Norway's stripe config should use NOK not EUR. diff --git a/package.json b/package.json index fe79e27e0..30ccb6035 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app", - "version": "9.1.0", + "version": "10.0.0", "private": true, "license": "Apache-2.0", "dependencies": {