Refactored buyersguide search.js, including:
- PNI tests for search
- github workflow update to run integration testing on CI
This commit is contained in:
Pomax 2021-12-15 15:31:44 -08:00 коммит произвёл GitHub
Родитель b5e42ed1ed
Коммит 43a9954dc9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 1208 добавлений и 823 удалений

60
.github/workflows/continous-integration.yml поставляемый
Просмотреть файл

@ -156,3 +156,63 @@ jobs:
run: npm run percy
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
test_integration:
name: Integration testing
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: network
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env:
ALLOWED_HOSTS: localhost,mozfest.localhost,default-site.com,secondary-site.com
CONTENT_TYPE_NO_SNIFF: True
CORS_ALLOWED_ORIGINS: "*"
DATABASE_URL: postgres://postgres:postgres@localhost:5432/network
DEBUG: True
DJANGO_SECRET_KEY: secret
DOMAIN_REDIRECT_MIDDLEWARE_ENABLED: False
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NETWORK_SITE_URL: https://foundation.mozilla.org
PIPENV_VERBOSITY: -1
PULSE_API_DOMAIN: https://network-pulse-api-production.herokuapp.com
PULSE_DOMAIN: https://www.mozillapulse.org
RANDOM_SEED: 530910203
SET_HSTS: False
SSL_REDIRECT: False
TARGET_DOMAINS: foundation.mozilla.org
USE_S3: False
X_FRAME_OPTIONS: DENY
XSS_PROTECTION: True
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.9.9
- uses: actions/setup-node@v2
with:
node-version: 14
- name: Install Python Dependencies
run: pip install -r requirements.txt -r dev-requirements.txt
- name: Install Node Dependencies
run: npm ci
- name: Install additional tooling
run: |
sudo apt-get install -y gettext libgconf-2-4
- name: Install Playwright
run: npm run playwright:install
- name: Preroll
run: |
npm run build
python network-api/manage.py collectstatic --no-input --verbosity 0
python network-api/manage.py migrate --no-input
python network-api/manage.py block_inventory
python network-api/manage.py load_fake_data
- name: Integration Tests
run: npm run playwright:ci

Просмотреть файл

@ -46,6 +46,7 @@ const sources = {
},
"bg-search": {
source: `buyers-guide/search.js`,
bundle: true,
},
polyfills: {
source: `polyfills.js`,

Просмотреть файл

@ -112,6 +112,11 @@ class ProductPageFactory(PageFactory):
product=self,
category=category
)
if category.parent:
ProductPageCategory.objects.get_or_create(
product=self,
category=category.parent
)
ceiling = ceiling / 5
else:
return

Просмотреть файл

@ -0,0 +1,36 @@
from django.db import migrations
from django.template.defaultfilters import slugify
def add_pni_subcategories(apps, schema):
BuyersGuideProductCategory = apps.get_model("wagtailpages", "BuyersGuideProductCategory")
healthAndExercise = BuyersGuideProductCategory.objects.get(name="Health & Exercise")
subcategories = [
"Exercise Equipment",
"Smart Scales",
"Smart Thermometers",
]
for cat in subcategories:
print(f"creating {cat}")
subcategory, created = BuyersGuideProductCategory.objects.get_or_create(
name=cat,
parent=healthAndExercise,
locale_id=healthAndExercise.locale_id,
)
if created:
subcategory.slug = slugify(cat)
subcategory.save()
class Migration(migrations.Migration):
dependencies = [
('wagtailpages', '0063_pulsefilter_pulsefilteroption'),
]
operations = [
migrations.RunPython(
add_pni_subcategories
),
]

27
package-lock.json сгенерированный
Просмотреть файл

@ -9745,23 +9745,6 @@
"node": ">=8.0.0"
}
},
"node_modules/playwright": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.17.0.tgz",
"integrity": "sha512-rBBrdj5+iYFBSsMa7glK3MBaxkcXxHN7uW6pY7PzTB7xtGFTfdK0h0BPB7/vWiVIGOvz4Hy9g2UJNaZDHkAmgA==",
"dev": true,
"hasInstallScript": true,
"peer": true,
"dependencies": {
"playwright-core": "=1.17.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/playwright-core": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.17.0.tgz",
@ -22477,16 +22460,6 @@
}
}
},
"playwright": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.17.0.tgz",
"integrity": "sha512-rBBrdj5+iYFBSsMa7glK3MBaxkcXxHN7uW6pY7PzTB7xtGFTfdK0h0BPB7/vWiVIGOvz4Hy9g2UJNaZDHkAmgA==",
"dev": true,
"peer": true,
"requires": {
"playwright-core": "=1.17.0"
}
},
"playwright-core": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.17.0.tgz",

Просмотреть файл

@ -32,14 +32,15 @@
"optimize:jpg": "find source/images -type f -name '*.jpg' -print0 | xargs -0 -n 1 -P 6 -I '{}' guetzli --quality 93 '{}' '{}'",
"optimize:png": "find source/images -type f -name '*.png' -print0 | xargs -0 -n 1 -P 6 optipng",
"optimize": "run-p optimize:**",
"percy": "percy exec -t 750 -- npm run playwright:ci",
"percy": "percy exec -t 750 -- npm run playwright:percy",
"playwright": "npm run playwright:wait:dev && playwright test ./tests/integration.spec.js",
"playwright:install": "playwright install chromium firefox webkit",
"playwright:percy": "npm run playwright:wait:dev && playwright test ./tests/visual.spec.js",
"playwright:wait:dev": "wait-on -i 3000 http://localhost:8000/static/_css/tailwind.compiled.css",
"playwright:ci": "run-p --race server playwright:visual",
"playwright:percy": "run-p --race server playwright:visual",
"playwright:urls": "wait-on -i 3000 http://localhost:8000/cms && playwright test ./tests/urls.spec.js",
"playwright:visual": "wait-on -i 3000 http://localhost:8000/cms && playwright test ./tests/visual.spec.js",
"playwright:ci": "run-p --race server playwright:integration",
"playwright:integration": "wait-on -i 3000 http://localhost:8000/cms && playwright test ./tests/integration.spec.js",
"precommit": "npm run test",
"server": "python network-api/manage.py runserver 0.0.0.0:8000",
"server:silent": "python network-api/manage.py runserver 0.0.0.0:8000 >> server.log 2>&1",

Просмотреть файл

@ -1,797 +1,9 @@
/**
* Set up PNI search functionality, as well as the PNI "ding" toggle.
* Set up the search/filter functionality for PNI pages
*/
// static values used throughout this code
const ALL_PRODUCTS = document.querySelectorAll(`figure.product-box`);
const NO_RESULTS_NOTICE = document.getElementById(
`product-filter-no-results-notice`
);
const FILTERS = [`company`, `name`, `blurb`, `worst-case`];
const SORTS = [`name`, `company`, `blurb`];
const SUBMIT_PRODUCT = document.querySelector(".recommend-product");
const CREEPINESS_FACE = document.querySelector(".creep-o-meter-information");
const categoryTitle = document.querySelector(`.category-title`);
const parentTitle = document.querySelector(`.parent-title`);
const toggle = document.querySelector(`#product-filter-pni-toggle`);
const subcategories = document.querySelectorAll(`.subcategories`);
const subContainer = document.querySelector(`.subcategory-header`);
import { SearchFilter } from "./search/search-filter.js";
import { PNIToggle } from "./search/pni-toggle.js";
// TODO: turn this into a static class rather than plain JS object.
const SearchFilter = {
init: () => {
let pos = { left: 0, x: 0 };
const subClasses = subContainer.classList;
const markScrollStart = (event) => {
event.preventDefault();
event.stopImmediatePropagation();
subClasses.add("cursor-grabbing", "select-none");
pos = {
left: subContainer.scrollLeft,
x: event.clientX,
};
[`mousemove`, `touchmove`].forEach((type) =>
document.addEventListener(type, markScrollMove)
);
[`mouseup`, `touchend`, `touchcancel`].forEach((type) =>
document.addEventListener(type, markScrollEnd)
);
};
const markScrollMove = (event) => {
subcategories.forEach((subcategory) => {
subcategory.classList.add("pointer-events-none");
});
const dx = event.clientX - pos.x;
subContainer.scrollLeft = pos.left - dx;
};
const markScrollEnd = (event) => {
event.preventDefault();
event.stopImmediatePropagation();
subcategories.forEach((subcategory) => {
subcategory.classList.remove("pointer-events-none");
});
subClasses.remove("cursor-grabbing", "select-none");
[`mousemove`, `touchmove`].forEach((type) =>
document.removeEventListener(type, markScrollMove)
);
[`mouseup`, `touchend`, `touchcancel`].forEach((type) =>
document.removeEventListener(type, markScrollEnd)
);
};
[`mousedown`, `touchstart`].forEach((type) =>
subContainer.addEventListener(type, markScrollStart)
);
const searchBar = document.querySelector(`#product-filter-search`);
if (!searchBar) {
return console.warn(
`Could not find the PNI search bar. Search will not be available.`
);
}
const searchInput = (SearchFilter.searchInput =
searchBar.querySelector(`input`));
searchInput.addEventListener(`input`, (evt) => {
const searchText = searchInput.value.trim();
if (searchText) {
searchBar.classList.add(`has-content`);
SearchFilter.filter(searchText);
} else {
clearText();
applyHistory();
}
});
const clear = searchBar.querySelector(`.clear-icon`);
if (!clear) {
return console.warn(
`Could not find the PNI search input clear icon. Search will work, but clearing will not.`
);
}
const clearText = () => {
searchBar.classList.remove(`has-content`);
searchInput.value = ``;
ALL_PRODUCTS.forEach((product) => {
product.classList.remove(`d-none`);
product.classList.add(`d-flex`);
});
history.replaceState(
{
...history.state,
search: "",
},
SearchFilter.getTitle(categoryTitle.value.trim()),
location.href
);
SearchFilter.sortOnCreepiness();
SearchFilter.moveCreepyFace();
};
const applyHistory = () => {
const { category, parent } = history.state;
categoryTitle.value = category;
parentTitle.value = parent;
if (parent) {
SearchFilter.highlightParent();
SearchFilter.toggleSubcategory();
} else {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="${category}"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="${category}"]`)
.classList.add(`active`);
SearchFilter.toggleSubcategory(true);
}
SearchFilter.filterCategory(category);
SearchFilter.filterSubcategory(parent || category);
SearchFilter.updateHeader(category, parent);
SearchFilter.sortOnCreepiness();
SearchFilter.moveCreepyFace();
if (history.state?.parent && history.state?.category) {
document
.querySelector(
`a.subcategories[data-name="${history.state?.category}"]`
)
.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}
};
clear.addEventListener(`click`, (evt) => {
evt.preventDefault();
searchInput.focus();
clearText();
applyHistory();
});
const navLinks = document.querySelectorAll(
`#multipage-nav a,.category-header,#pni-nav-mobile a`
);
for (const nav of navLinks) {
nav.addEventListener("click", (evt) => {
evt.stopPropagation();
if (evt.shiftKey || evt.metaKey || evt.ctrlKey || evt.altKey) {
return;
}
evt.preventDefault();
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector("#pni-nav-mobile .dropdown-nav")
.classList.remove("dropdown-nav-open");
if (evt.target.dataset.name) {
document
.querySelector(
`#multipage-nav a[data-name="${evt.target.dataset.name}"]`
)
.classList.add(`active`);
document
.querySelector(
`#pni-nav-mobile a[data-name="${evt.target.dataset.name}"]`
)
.classList.add(`active`);
clearText();
history.pushState(
{
title: SearchFilter.getTitle(evt.target.dataset.name),
category: evt.target.dataset.name,
parent: "",
search: "",
filter: history.state?.filter,
},
SearchFilter.getTitle(evt.target.dataset.name),
evt.target.href
);
document.title = SearchFilter.getTitle(evt.target.dataset.name);
SearchFilter.filterSubcategory(evt.target.dataset.name);
SearchFilter.toggleSubcategory(true);
SearchFilter.updateHeader(evt.target.dataset.name, "");
SearchFilter.filterCategory(evt.target.dataset.name);
}
});
}
for (const subcategory of subcategories) {
subcategory.addEventListener(
"click",
(evt) => {
evt.stopImmediatePropagation();
if (evt.shiftKey || evt.metaKey || evt.ctrlKey || evt.altKey) {
return;
}
evt.preventDefault();
let href;
if (evt.target.dataset.name) {
clearText();
if (categoryTitle.value.trim() !== evt.target.dataset.name) {
categoryTitle.value = evt.target.dataset.name;
parentTitle.value = evt.target.dataset.parent;
href = evt.target.href;
SearchFilter.toggleSubcategory();
SearchFilter.highlightParent();
} else {
categoryTitle.value = evt.target.dataset.parent;
parentTitle.value = "";
href = document.querySelector(
`#multipage-nav a[data-name="${evt.target.dataset.parent}"]`
).href;
SearchFilter.toggleSubcategory(true);
}
history.pushState(
{
title: SearchFilter.getTitle(evt.target.dataset.name),
category: categoryTitle.value.trim(),
parent: parentTitle.value.trim(),
search: "",
filter: history.state?.filter,
},
SearchFilter.getTitle(evt.target.dataset.name),
href
);
document.title = SearchFilter.getTitle(categoryTitle.value.trim());
SearchFilter.updateHeader(
categoryTitle.value.trim(),
parentTitle.value.trim()
);
SearchFilter.filterCategory(categoryTitle.value.trim());
}
},
true
);
}
document
.querySelector(`.go-back-to-all-link`)
.addEventListener("click", (evt) => {
evt.stopPropagation();
if (evt.shiftKey || evt.metaKey || evt.ctrlKey || evt.altKey) {
return;
}
evt.preventDefault();
clearText();
history.pushState(
{
title: SearchFilter.getTitle("None"),
category: "None",
parent: "",
search: "",
filter: history.state?.filter,
},
SearchFilter.getTitle(evt.target.dataset.name),
evt.target.href
);
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="None"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="None"]`)
.classList.add(`active`);
SearchFilter.filterCategory("None");
parentTitle.value = "";
});
window.addEventListener(`popstate`, (event) => {
const { state } = event;
if (!state) return; // if it's a "real" back, we shouldn't need to do anything
const { title, category, parent } = state;
document.title = title;
if (!history.state?.search) {
SearchFilter.clearCategories();
categoryTitle.value = category;
parentTitle.value = parent;
searchBar.classList.remove(`has-content`);
searchInput.value = ``;
if (parent) {
SearchFilter.highlightParent();
SearchFilter.toggleSubcategory();
} else {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="${category}"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="${category}"]`)
.classList.add(`active`);
SearchFilter.toggleSubcategory(true);
}
} else {
SearchFilter.toggleSubcategory(true);
searchBar.classList.add(`has-content`);
searchInput.value = history.state?.search;
SearchFilter.filter(history.state?.search);
}
SearchFilter.filterCategory(category);
SearchFilter.filterSubcategory(parent || category);
SearchFilter.updateHeader(category, parent);
if (history.state?.filter) {
toggle.checked = history.state?.filter;
if (history.state?.filter) {
document.body.classList.add(`show-ding-only`);
} else {
document.body.classList.remove(`show-ding-only`);
}
}
});
history.replaceState(
{
title: SearchFilter.getTitle(categoryTitle.value.trim()),
category: categoryTitle.value.trim(),
parent: parentTitle.value.trim(),
search: history.state?.search ?? "",
filter: history.state?.filter,
},
SearchFilter.getTitle(categoryTitle.value.trim()),
location.href
);
if (history.state?.search) {
searchBar.classList.add(`has-content`);
searchInput.value = history.state?.search;
SearchFilter.filter(history.state?.search);
} else {
searchBar.classList.remove(`has-content`);
searchInput.value = ``;
}
if (history.state?.filter) {
toggle.checked = history.state?.filter;
if (history.state?.filter) {
document.body.classList.add(`show-ding-only`);
} else {
document.body.classList.remove(`show-ding-only`);
}
}
if (history.state?.parent && history.state?.category) {
document
.querySelector(
`a.subcategories[data-name="${history.state?.category}"]`
)
.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}
},
clearCategories: () => {
SearchFilter.filterCategory("None");
parentTitle.value = null;
if (document.querySelector(`#multipage-nav a.active`)) {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="None"]`)
.classList.add(`active`);
}
if (document.querySelector(`#pni-nav-mobile a.active`)) {
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="None"]`)
.classList.add(`active`);
}
},
updateHeader: (category, parent) => {
if (parent) {
document.querySelector(".category-header").textContent = parent;
document.querySelector(".category-header").dataset.name = parent;
document.querySelector(".category-header").href = document.querySelector(
`#multipage-nav a[data-name="${parent}"]`
).href;
document.querySelector(`#pni-nav-mobile .active-link-label`).textContent =
parent;
} else {
const header = category === "None" ? gettext("All") : category;
document.querySelector(".category-header").textContent = header;
document.querySelector(".category-header").dataset.name = category;
document.querySelector(".category-header").href = document.querySelector(
`#multipage-nav a[data-name="${category}"]`
).href;
document.querySelector(`#pni-nav-mobile .active-link-label`).textContent =
category === "None"
? document.querySelector(`#multipage-nav a[data-name="None"]`)
.textContent
: category;
}
},
filterSubcategory: (category) => {
for (const subcategory of subcategories) {
if (subcategory.dataset.parent === category) {
subcategory.classList.remove(`tw-hidden`);
} else {
subcategory.classList.add(`tw-hidden`);
}
}
},
getTitle: (category) => {
if (category == "None")
return document.querySelector('meta[name="pni-home-title"]').content;
else {
return `${category} | ${
document.querySelector('meta[name="pni-category-title"]').content
}`;
}
},
moveCreepyFace: () => {
// When searching, check to see how many products are still visible
// If there are no visible products, there are "no search results"
// And when there are no search results, do not show the creepo-meter-face
if (document.querySelectorAll(".product-box:not(.d-none)").length) {
// If there are search results, show the creepo-meter-face
CREEPINESS_FACE.classList.remove("d-none");
} else {
// If there are no search results, hide the creepo-meter-face
CREEPINESS_FACE.classList.add("d-none");
}
},
filter: (text) => {
// remove category filters
SearchFilter.clearCategories();
SearchFilter.toggleSubcategory(true);
SearchFilter.filterSubcategory("None");
SearchFilter.updateHeader("None", null);
if (document.querySelector(`#multipage-nav a.active`)) {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
}
if (document.querySelector(`#pni-nav-mobile a.active`)) {
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
}
document
.querySelector(`#multipage-nav a[data-name="None"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="None"]`)
.classList.add(`active`);
ALL_PRODUCTS.forEach((product) => {
if (SearchFilter.test(product, text)) {
product.classList.remove(`d-none`);
product.classList.add(`d-flex`);
} else {
product.classList.add(`d-none`);
product.classList.remove(`d-flex`);
}
});
history.replaceState(
{
...history.state,
search: text,
},
SearchFilter.getTitle(categoryTitle.value.trim()),
location.href
);
SearchFilter.sortProducts();
SearchFilter.moveCreepyFace();
SearchFilter.checkForEmptyNotice();
},
sortProducts: () => {
const container = document.querySelector(`.product-box-list`);
const list = [...container.querySelectorAll(`.product-box`)];
list.sort((a, b) => {
for (const field of SORTS) {
const qs = `.product-${field}`;
const [propertyA, propertyB] = [
a.querySelector(qs),
b.querySelector(qs),
];
const [propertyNameA, propertyNameB] = [
(propertyA.value || propertyA.textContent).toLowerCase(),
(propertyB.value || propertyB.textContent).toLowerCase(),
];
if (
propertyNameA !== propertyNameB ||
field === SORTS[SORTS.length - 1]
) {
return propertyNameA < propertyNameB
? -1
: propertyNameA > propertyNameB
? 1
: 0;
}
}
});
list.forEach((p) => container.append(p));
},
sortOnCreepiness: () => {
const container = document.querySelector(`.product-box-list`);
const list = [...container.querySelectorAll(`.product-box`)];
const creepVal = (e) => parseFloat(e.dataset.creepiness);
list
.sort((a, b) => creepVal(a) - creepVal(b))
.forEach((p) => container.append(p));
},
filterCategory: (category) => {
ALL_PRODUCTS.forEach((product) => {
if (SearchFilter.testCategories(product, category)) {
product.classList.remove(`d-none`);
product.classList.add(`d-flex`);
} else {
product.classList.add(`d-none`);
product.classList.remove(`d-flex`);
}
});
categoryTitle.value = category;
SearchFilter.sortOnCreepiness();
SearchFilter.moveCreepyFace();
SearchFilter.checkForEmptyNotice();
},
highlightParent: () => {
if (document.querySelector(`#multipage-nav a.active`)) {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
}
if (document.querySelector(`#pni-nav-mobile a.active`)) {
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
}
document
.querySelector(
`#pni-nav-mobile a[data-name="${parentTitle.value.trim()}"]`
)
.classList.add(`active`);
document
.querySelector(
`#multipage-nav a[data-name="${parentTitle.value.trim()}"]`
)
.classList.add(`active`);
},
toggleSubcategory: (clear = false) => {
const activeClasses = [
"active",
"tw-bg-gray-80",
"tw-text-white",
"tw-border-gray-80",
];
const defaultClasses = [
"hover:tw-border-pni-lilac",
"hover:tw-bg-pni-lilac",
"tw-text-gray-60",
"tw-border-gray-20",
"tw-bg-white",
];
if (document.querySelector(`a.subcategories.active`)) {
document
.querySelector(`a.subcategories.active`)
.classList.add(...defaultClasses);
document
.querySelector(`a.subcategories.active`)
.classList.remove(...activeClasses);
}
if (clear) {
return;
}
document
.querySelector(
`a.subcategories[data-name="${categoryTitle.value.trim()}"]`
)
.classList.add(...activeClasses);
document
.querySelector(
`a.subcategories[data-name="${categoryTitle.value.trim()}"]`
)
.classList.remove(...defaultClasses);
document
.querySelector(
`a.subcategories[data-name="${categoryTitle.value.trim()}"]`
)
.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
},
checkForEmptyNotice: () => {
let qs = `figure.product-box:not(.d-none)`;
if (document.body.classList.contains(`show-ding-only`)) {
qs = `${qs}.privacy-ding`;
}
const results = document.querySelectorAll(qs);
const count = results.length;
if (count === 0) {
NO_RESULTS_NOTICE.classList.remove(`d-none`);
SUBMIT_PRODUCT.classList.add("d-none");
} else {
NO_RESULTS_NOTICE.classList.add(`d-none`);
SUBMIT_PRODUCT.classList.remove("d-none");
}
},
test: (product, text) => {
text = text.toLowerCase(); // Note that this is absolutely not true for all languages, but it's true for us.
let qs, data;
for (const field of FILTERS) {
qs = `.product-${field}`;
data = product.querySelector(qs);
data = (data.value || data.textContent).toLowerCase();
if (data.indexOf(text) !== -1) {
return true;
}
}
return false;
},
testCategories: (product, category) => {
if (category === "None") {
return true;
}
const productCategories = Array.from(
product.querySelectorAll(".product-categories")
);
return productCategories.map((c) => c.value.trim()).includes(category);
},
};
// TODO: turn this into a static class as well
const PNIToggle = {
init: () => {
if (!toggle) {
return console.warn(
`Could not find the PNI filter checkbox. PNI filtering will not be available.`
);
}
toggle.addEventListener(`change`, (evt) => {
const filter = evt.target.checked;
// TODO: this might be an A/B testing opportunity to see
// whether users assume this toggle is a navigation
// action or not?
history.replaceState(
{
...history.state,
filter,
},
SearchFilter.getTitle(categoryTitle.value.trim()),
location.href
);
if (filter) {
document.body.classList.add(`show-ding-only`);
} else {
document.body.classList.remove(`show-ding-only`);
}
if (SearchFilter.searchInput.value.trim()) {
SearchFilter.searchInput.focus();
SearchFilter.checkForEmptyNotice();
}
});
},
};
// bootstrap both searching and privacy-ding-filtering
SearchFilter.init();
PNIToggle.init();
const searchFilter = new SearchFilter();
new PNIToggle(searchFilter);

Просмотреть файл

@ -0,0 +1,15 @@
- [x] land Playwright testing
- [x] create PNI search test(s)
- [x] move all `init()` calls out as plain functions
- [x] updated the base data to include subcategories for a PNI main category.
- [x] update tests to check for subcategory behaviour.
- [x] continue the refactor
- [x] move all "object functions" in search over as (temporary) "class functions"
- [x] move all "can be moved to utils" functions into their own (possibly more than one) util modules
- [x] move the setupXYZ functions back into the class (where sensible)
- [x] we should be done with the initial refactor now
Outstanding tasks:
- check if we can create a single history update function, as we call build and set state in several different places at the moment.
- try to move as many consts into classes/constructors rather than keeping them bare consts

Просмотреть файл

@ -0,0 +1,25 @@
const CREEPINESS_FACE = document.querySelector(".creep-o-meter-information");
export class CreepUtils {
static moveCreepyFace() {
// When searching, check to see how many products are still visible
// If there are no visible products, there are "no search results"
// And when there are no search results, do not show the creepo-meter-face
if (document.querySelectorAll(".product-box:not(.d-none)").length) {
// If there are search results, show the creepo-meter-face
CREEPINESS_FACE.classList.remove("d-none");
} else {
// If there are no search results, hide the creepo-meter-face
CREEPINESS_FACE.classList.add("d-none");
}
}
static sortOnCreepiness() {
const container = document.querySelector(`.product-box-list`);
const list = [...container.querySelectorAll(`.product-box`)];
const creepVal = (e) => parseFloat(e.dataset.creepiness);
list
.sort((a, b) => creepVal(a) - creepVal(b))
.forEach((p) => container.append(p));
}
}

Просмотреть файл

@ -0,0 +1,183 @@
import { Utils } from "./utils.js";
import { CreepUtils } from "./creep-utils.js";
const categoryTitle = document.querySelector(`.category-title`);
const parentTitle = document.querySelector(`.parent-title`);
const toggle = document.querySelector(`#product-filter-pni-toggle`);
/**
* ...
* @param {*} instance
* @param {*} searchBar
* @param {*} searchInput
*/
export function setupHistoryManagement(instance, searchBar, searchInput) {
setupPopStateHandler(instance, searchBar, searchInput);
performInitialHistoryReplace(instance, searchBar, searchInput);
}
/**
* ...
* @param {*} instance
* @param {*} searchBar
* @param {*} searchInput
*/
export function performInitialHistoryReplace(instance, searchBar, searchInput) {
history.replaceState(
{
title: Utils.getTitle(categoryTitle.value.trim()),
category: categoryTitle.value.trim(),
parent: parentTitle.value.trim(),
search: history.state?.search ?? "",
filter: history.state?.filter,
},
Utils.getTitle(categoryTitle.value.trim()),
location.href
);
if (history.state?.search) {
searchBar.classList.add(`has-content`);
searchInput.value = history.state?.search;
instance.filter(history.state?.search);
} else {
searchBar.classList.remove(`has-content`);
searchInput.value = ``;
}
if (history.state?.filter) {
toggle.checked = history.state?.filter;
if (history.state?.filter) {
document.body.classList.add(`show-ding-only`);
} else {
document.body.classList.remove(`show-ding-only`);
}
}
if (history.state?.parent && history.state?.category) {
document
.querySelector(`a.subcategories[data-name="${history.state?.category}"]`)
.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}
}
/**
* ...
* @param {*} instance
* @param {*} searchBar
* @param {*} searchInput
*/
export function setupPopStateHandler(instance, searchBar, searchInput) {
window.addEventListener(`popstate`, (event) => {
const { state } = event;
if (!state) return; // if it's a "real" back, we shouldn't need to do anything
const { title, category, parent } = state;
document.title = title;
if (!history.state?.search) {
instance.clearCategories();
categoryTitle.value = category;
parentTitle.value = parent;
searchBar.classList.remove(`has-content`);
searchInput.value = ``;
if (parent) {
Utils.highlightParentCategory();
instance.toggleSubcategory();
} else {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="${category}"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="${category}"]`)
.classList.add(`active`);
instance.toggleSubcategory(true);
}
} else {
instance.toggleSubcategory(true);
searchBar.classList.add(`has-content`);
searchInput.value = history.state?.search;
instance.filter(history.state?.search);
}
instance.filterCategory(category);
instance.filterSubcategory(parent || category);
Utils.updateHeader(category, parent);
if (history.state?.filter) {
toggle.checked = history.state?.filter;
if (history.state?.filter) {
document.body.classList.add(`show-ding-only`);
} else {
document.body.classList.remove(`show-ding-only`);
}
}
});
}
/**
* ...
* @param {*} instance
*/
export function applyHistory(instance) {
const { category, parent } = history.state;
categoryTitle.value = category;
parentTitle.value = parent;
if (parent) {
Utils.highlightParentCategory();
instance.toggleSubcategory();
} else {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="${category}"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="${category}"]`)
.classList.add(`active`);
instance.toggleSubcategory(true);
}
instance.filterCategory(category);
instance.filterSubcategory(parent || category);
Utils.updateHeader(category, parent);
CreepUtils.sortOnCreepiness();
CreepUtils.moveCreepyFace();
if (history.state?.parent && history.state?.category) {
document
.querySelector(`a.subcategories[data-name="${history.state?.category}"]`)
.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}
}

Просмотреть файл

@ -0,0 +1,180 @@
import { Utils } from "./utils.js";
const categoryTitle = document.querySelector(`.category-title`);
const parentTitle = document.querySelector(`.parent-title`);
const subcategories = document.querySelectorAll(`.subcategories`);
/**
* ...
* @param {*} instance
*/
export function setupNavLinks(instance) {
const navLinks = document.querySelectorAll(
`#multipage-nav a,.category-header,#pni-nav-mobile a`
);
for (const nav of navLinks) {
nav.addEventListener("click", (evt) => {
evt.stopPropagation();
if (evt.shiftKey || evt.metaKey || evt.ctrlKey || evt.altKey) {
return;
}
evt.preventDefault();
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector("#pni-nav-mobile .dropdown-nav")
.classList.remove("dropdown-nav-open");
const { name: categoryName } = evt.target.dataset ?? {};
if (categoryName) {
document
.querySelector(
`#multipage-nav a[data-name="${categoryName}"]`
)
.classList.add(`active`);
document
.querySelector(
`#pni-nav-mobile a[data-name="${categoryName}"]`
)
.classList.add(`active`);
instance.clearText();
history.pushState(
{
title: Utils.getTitle(categoryName),
category: categoryName,
parent: "",
search: "",
filter: history.state?.filter,
},
Utils.getTitle(categoryName),
evt.target.href
);
document.title = Utils.getTitle(categoryName);
instance.filterSubcategory(categoryName);
instance.toggleSubcategory(true);
Utils.updateHeader(categoryName, "");
instance.filterCategory(categoryName);
}
});
}
for (const subcategory of subcategories) {
subcategory.addEventListener(
"click",
(evt) => {
evt.stopImmediatePropagation();
if (evt.shiftKey || evt.metaKey || evt.ctrlKey || evt.altKey) {
return;
}
evt.preventDefault();
let href;
const { name: subcategoryName } = evt.target.dataset;
if (subcategoryName) {
instance.clearText();
if (categoryTitle.value.trim() !== subcategoryName) {
categoryTitle.value = subcategoryName;
parentTitle.value = evt.target.dataset.parent;
href = evt.target.href;
instance.toggleSubcategory();
Utils.highlightParentCategory();
} else {
categoryTitle.value = evt.target.dataset.parent;
parentTitle.value = "";
href = document.querySelector(
`#multipage-nav a[data-name="${evt.target.dataset.parent}"]`
).href;
instance.toggleSubcategory(true);
}
history.pushState(
{
title: Utils.getTitle(subcategoryName),
category: categoryTitle.value.trim(),
parent: parentTitle.value.trim(),
search: "",
filter: history.state?.filter,
},
Utils.getTitle(subcategoryName),
href
);
document.title = Utils.getTitle(categoryTitle.value.trim());
Utils.updateHeader(
categoryTitle.value.trim(),
parentTitle.value.trim()
);
instance.filterCategory(categoryTitle.value.trim());
}
},
true
);
}
}
/**
* ...
* @param {*} instance
*/
export function setupGoBackToAll(instance) {
document
.querySelector(`.go-back-to-all-link`)
.addEventListener("click", (evt) => {
evt.stopPropagation();
if (evt.shiftKey || evt.metaKey || evt.ctrlKey || evt.altKey) {
return;
}
evt.preventDefault();
instance.clearText();
history.pushState(
{
title: Utils.getTitle("None"),
category: "None",
parent: "",
search: "",
filter: history.state?.filter,
},
Utils.getTitle(evt.target.dataset.name),
evt.target.href
);
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="None"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="None"]`)
.classList.add(`active`);
instance.filterCategory("None");
parentTitle.value = "";
});
}

Просмотреть файл

@ -0,0 +1,42 @@
import { Utils } from "./utils.js";
export class PNIToggle {
constructor(searchFilter) {
this.searchFilter = searchFilter;
this.categoryTitle = document.querySelector(`.category-title`);
this.toggle = document.querySelector(`#product-filter-pni-toggle`);
if (!this.toggle) {
// TODO: this should become a throw, with enough integration tests that
// we can be confident that any page that should have it, has it,
// failing our tests on pages that should but don't.
return console.warn(
`Could not find the PNI filter checkbox. PNI filtering will not be available.`
);
}
this.toggle.addEventListener(`change`, (evt) => {
const doFilter = !!evt.target.checked;
this.togglePrivacyOnly(doFilter);
});
}
togglePrivacyOnly(doFilter) {
const { searchFilter, categoryTitle } = this;
// TODO: this might be an A/B testing opportunity to see
// whether users assume this toggle is a navigation
// action or not?
const state = { ...history.state, filter: doFilter };
const title = Utils.getTitle(categoryTitle.value.trim());
history.replaceState(state, title, location.href);
const toggleFunction = doFilter ? `add` : `remove`;
document.body.classList[toggleFunction](`show-ding-only`);
if (searchFilter.searchInput.value.trim()) {
searchFilter.searchInput.focus();
Utils.checkForEmptyNotice();
}
}
}

Просмотреть файл

@ -0,0 +1,193 @@
import { Utils } from "./utils.js";
import { CreepUtils } from "./creep-utils.js";
import { markScrollStart } from "./slider-area.js";
import { setupHistoryManagement, applyHistory } from "./history.js";
import { setupNavLinks, setupGoBackToAll } from "./member-functions.js";
/**
* ...
*/
export class SearchFilter {
constructor() {
const { searchBar, searchInput } = this.setupSearchBar();
setupNavLinks(this);
setupGoBackToAll(this);
setupHistoryManagement(this, searchBar, searchInput);
const subContainer = document.querySelector(`.subcategory-header`);
[`mousedown`, `touchstart`].forEach((type) =>
subContainer.addEventListener(type, markScrollStart)
);
this.allProducts = document.querySelectorAll(`figure.product-box`);
this.categoryTitle = document.querySelector(`.category-title`);
}
/**
* Set up the search filter functionality, and return the
* searchbar and searchinput elements for external function
* binding purposes.
*/
setupSearchBar() {
const searchBar = (this.searchBar = document.querySelector(
`#product-filter-search`
));
if (!searchBar) {
return console.warn(
`Could not find the PNI search bar. Search will not be available.`
);
}
const searchInput = (this.searchInput = searchBar.querySelector(`input`));
searchInput.addEventListener(`input`, (evt) => {
const searchText = searchInput.value.trim();
if (searchText) {
searchBar.classList.add(`has-content`);
this.filter(searchText);
} else {
this.clearText();
applyHistory(this);
}
});
const clear = searchBar.querySelector(`.clear-icon`);
if (!clear) {
return console.warn(
`Could not find the PNI search input clear icon. Search will work, but clearing will not.`
);
}
clear.addEventListener(`click`, (evt) => {
evt.preventDefault();
searchInput.focus();
this.clearText();
applyHistory(this);
});
return { searchBar, searchInput };
}
/**
* Clear the search text
*/
clearText() {
const { searchBar, searchInput } = this;
searchBar.classList.remove(`has-content`);
searchInput.value = ``;
this.allProducts.forEach((product) => {
product.classList.remove(`d-none`);
product.classList.add(`d-flex`);
});
CreepUtils.sortOnCreepiness();
CreepUtils.moveCreepyFace();
const state = { ...history.state, search: "" };
const title = Utils.getTitle(this.categoryTitle.value.trim());
history.replaceState(state, title, location.href);
}
/**
* Searching is really "filtering", toggling visibility for products
* based on whether they match the given text (from amongst several
* possible product fields).
* @param {*} text
*/
filter(text) {
this.clearCategories();
this.toggleSubcategory(true);
this.filterSubcategory("None");
Utils.updateHeader("None", null);
Utils.selectAllCategory();
Utils.toggleProducts(text);
const state = { ...history.state, search: text };
const title = Utils.getTitle(this.categoryTitle.value.trim());
history.replaceState(state, title, location.href);
Utils.sortFilteredProducts();
CreepUtils.moveCreepyFace();
Utils.checkForEmptyNotice();
}
clearCategories() {
const parentTitle = document.querySelector(`.parent-title`);
parentTitle.value = null;
this.filterCategory("None");
Utils.clearCategories();
}
filterCategory(category) {
Utils.showProductsForCategory(category);
this.categoryTitle.value = category;
CreepUtils.sortOnCreepiness();
CreepUtils.moveCreepyFace();
Utils.checkForEmptyNotice();
}
toggleSubcategory(clear = false) {
const activeClasses = [
"active",
"tw-bg-gray-80",
"tw-text-white",
"tw-border-gray-80",
];
const defaultClasses = [
"hover:tw-border-pni-lilac",
"hover:tw-bg-pni-lilac",
"tw-text-gray-60",
"tw-border-gray-20",
"tw-bg-white",
];
if (document.querySelector(`a.subcategories.active`)) {
document
.querySelector(`a.subcategories.active`)
.classList.add(...defaultClasses);
document
.querySelector(`a.subcategories.active`)
.classList.remove(...activeClasses);
}
if (clear === true) return;
this.activateSubcategory(activeClasses, defaultClasses);
}
activateSubcategory(activeClasses, defaultClasses) {
const categoryName = this.categoryTitle.value.trim();
document
.querySelector(`a.subcategories[data-name="${categoryName}"]`)
.classList.add(...activeClasses);
document
.querySelector(`a.subcategories[data-name="${categoryName}"]`)
.classList.remove(...defaultClasses);
document
.querySelector(`a.subcategories[data-name="${categoryName}"]`)
.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}
filterSubcategory(category) {
const subcategories = document.querySelectorAll(`.subcategories`);
for (const subcategory of subcategories) {
if (subcategory.dataset.parent === category) {
subcategory.classList.remove(`tw-hidden`);
} else {
subcategory.classList.add(`tw-hidden`);
}
}
}
}

Просмотреть файл

@ -0,0 +1,59 @@
/**
* mouse/touch scroll functionality for the category area
* @param {*} event
*/
const subcategories = document.querySelectorAll(`.subcategories`);
const subContainer = document.querySelector(`.subcategory-header`);
const subClasses = subContainer.classList;
let pos = { left: 0, x: 0 };
function stop(evt) {
evt.preventDefault();
evt.stopImmediatePropagation();
}
export function markScrollStart(event) {
stop(event);
subClasses.add("cursor-grabbing", "select-none");
pos = {
left: subContainer.scrollLeft,
x: event.clientX,
};
[`mousemove`, `touchmove`].forEach((type) =>
document.addEventListener(type, markScrollMove)
);
[`mouseup`, `touchend`, `touchcancel`].forEach((type) =>
document.addEventListener(type, markScrollEnd)
);
}
function markScrollMove(event) {
subcategories.forEach((subcategory) => {
subcategory.classList.add("pointer-events-none");
});
const dx = event.clientX - pos.x;
subContainer.scrollLeft = pos.left - dx;
}
function markScrollEnd(event) {
stop(event);
subcategories.forEach((subcategory) => {
subcategory.classList.remove("pointer-events-none");
});
subClasses.remove("cursor-grabbing", "select-none");
[`mousemove`, `touchmove`].forEach((type) =>
document.removeEventListener(type, markScrollMove)
);
[`mouseup`, `touchend`, `touchcancel`].forEach((type) =>
document.removeEventListener(type, markScrollEnd)
);
}

Просмотреть файл

@ -0,0 +1,263 @@
const SORTS = [`name`, `company`, `blurb`];
const FILTERS = [`company`, `name`, `blurb`, `worst-case`];
const ALL_PRODUCTS = document.querySelectorAll(`figure.product-box`);
const SUBMIT_PRODUCT = document.querySelector(".recommend-product");
const NO_RESULTS_NOTICE = document.getElementById(
`product-filter-no-results-notice`
);
const ALL_CATEGORY_LABEL = document.querySelector(
`#multipage-nav .multipage-link[data-name="None"]`
).textContent;
const PARENT_TITLE = document.querySelector(`.parent-title`);
export class Utils {
/**
*...
* @param {*} category
* @returns
*/
static getTitle(category) {
if (category == "None")
return document.querySelector('meta[name="pni-home-title"]').content;
else {
return `${category} | ${
document.querySelector('meta[name="pni-category-title"]').content
}`;
}
}
/**
* ...
* @param {*} category
* @param {*} parent
*/
static updateHeader(category, parent) {
if (parent) {
document.querySelector(".category-header").textContent = parent;
document.querySelector(".category-header").dataset.name = parent;
document.querySelector(".category-header").href = document.querySelector(
`#multipage-nav a[data-name="${parent}"]`
).href;
document.querySelector(`#pni-nav-mobile .active-link-label`).textContent =
parent;
} else {
const header = category === "None" ? ALL_CATEGORY_LABEL : category;
document.querySelector(".category-header").textContent = header;
document.querySelector(".category-header").dataset.name = category;
document.querySelector(".category-header").href = document.querySelector(
`#multipage-nav a[data-name="${category}"]`
).href;
document.querySelector(`#pni-nav-mobile .active-link-label`).textContent =
category === "None"
? document.querySelector(`#multipage-nav a[data-name="None"]`)
.textContent
: category;
}
}
/**
* ...
*/
static clearCategories() {
if (document.querySelector(`#multipage-nav a.active`)) {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
document
.querySelector(`#multipage-nav a[data-name="None"]`)
.classList.add(`active`);
}
if (document.querySelector(`#pni-nav-mobile a.active`)) {
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="None"]`)
.classList.add(`active`);
}
}
/**
* ...
*/
static selectAllCategory() {
if (document.querySelector(`#multipage-nav a.active`)) {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
}
if (document.querySelector(`#pni-nav-mobile a.active`)) {
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
}
document
.querySelector(`#multipage-nav a[data-name="None"]`)
.classList.add(`active`);
document
.querySelector(`#pni-nav-mobile a[data-name="None"]`)
.classList.add(`active`);
}
/**
*
*/
static highlightParentCategory() {
if (document.querySelector(`#multipage-nav a.active`)) {
document
.querySelector(`#multipage-nav a.active`)
.classList.remove(`active`);
}
if (document.querySelector(`#pni-nav-mobile a.active`)) {
document
.querySelector(`#pni-nav-mobile a.active`)
.classList.remove(`active`);
}
document
.querySelector( `#pni-nav-mobile a[data-name="${PARENT_TITLE.value.trim()}"]`
)
.classList.add(`active`);
document
.querySelector( `#multipage-nav a[data-name="${PARENT_TITLE.value.trim()}"]`
)
.classList.add(`active`);
}
/**
* ...
* @param {*} text
*/
static toggleProducts(text) {
ALL_PRODUCTS.forEach((product) => {
if (this.test(product, text)) {
product.classList.remove(`d-none`);
product.classList.add(`d-flex`);
} else {
product.classList.add(`d-none`);
product.classList.remove(`d-flex`);
}
});
}
/**
* ...
* @param {*} category
*/
static showProductsForCategory(category) {
ALL_PRODUCTS.forEach((product) => {
if (this.testCategories(product, category)) {
product.classList.remove(`d-none`);
product.classList.add(`d-flex`);
} else {
product.classList.add(`d-none`);
product.classList.remove(`d-flex`);
}
});
}
/**
* ...
* @param {*} product
* @param {*} text
* @returns
*/
static test(product, text) {
// Note that the following is absolutely not true for all
// languages, but it's true for the ones we use.
text = text.toLowerCase();
let qs, data;
for (const field of FILTERS) {
qs = `.product-${field}`;
data = product.querySelector(qs);
data = (data.value || data.textContent).toLowerCase();
if (data.indexOf(text) !== -1) {
return true;
}
}
return false;
}
/**
* ...
* @param {*} product
* @param {*} category
* @returns
*/
static testCategories(product, category) {
if (category === "None") {
return true;
}
const productCategories = Array.from(
product.querySelectorAll(".product-categories")
);
return productCategories.map((c) => c.value.trim()).includes(category);
}
/**
* ...
*/
static sortFilteredProducts() {
const container = document.querySelector(`.product-box-list`);
const list = [...container.querySelectorAll(`.product-box`)];
list.sort((a, b) => {
for (const field of SORTS) {
const qs = `.product-${field}`;
const [propertyA, propertyB] = [
a.querySelector(qs),
b.querySelector(qs),
];
const [propertyNameA, propertyNameB] = [
(propertyA.value || propertyA.textContent).toLowerCase(),
(propertyB.value || propertyB.textContent).toLowerCase(),
];
if (
propertyNameA !== propertyNameB ||
field === SORTS[SORTS.length - 1]
) {
return propertyNameA < propertyNameB
? -1
: propertyNameA > propertyNameB
? 1
: 0;
}
}
});
list.forEach((p) => container.append(p));
}
/**
*
*/
static checkForEmptyNotice() {
let qs = `figure.product-box:not(.d-none)`;
if (document.body.classList.contains(`show-ding-only`)) {
qs = `${qs}.privacy-ding`;
}
const results = document.querySelectorAll(qs);
if (results.length === 0) {
NO_RESULTS_NOTICE.classList.remove(`d-none`);
SUBMIT_PRODUCT.classList.add("d-none");
} else {
NO_RESULTS_NOTICE.classList.add(`d-none`);
SUBMIT_PRODUCT.classList.remove("d-none");
}
}
}

Просмотреть файл

@ -32,3 +32,140 @@ test(`Foundation homepage`, async ({ page }, testInfo) => {
expect(await countryPicker.isVisible()).toBe(true);
expect(await languagePicker.isVisible()).toBe(true);
});
/**
* Perform several PNI tests related to searching/filtering
*
* NOTE: this requires a `new-db` run with the seed value set
* through RANDOM_SEED=530910203 in your .env file
*/
test(`PNI search`, async ({ page }, testInfo) => {
page.on(`console`, console.log);
await page.goto(`http://localhost:8000/en/privacynotincluded`);
await page.locator(`body.react-loaded`);
await waitForImagesToLoad(page);
const counts = {
// provided RANDOM_SEED=530910203 was used!
total: 41,
health: 14,
smart: 4,
percy: 2,
the: 14,
theWithDing: 4,
};
const qs = {
ding: `#product-filter-pni-toggle`,
dingLabel: `label[for="product-filter-pni-toggle"]`,
products: `.product-box:visible`,
searchBar: `#product-filter-search-input`,
clearSearch: `label[for="product-filter-search-input"]`,
activeCategory: `#multipage-nav a.multipage-link.active`,
activeSubCategory: `a.subcategories.active`,
healthCategory: `#multipage-nav a.multipage-link[data-name="Health & Exercise"]`,
};
let products, activeCategory;
// verify that the PNI "ding" is not selected on initial load
const ding = page.locator(qs.ding);
await expect(ding).not.toBeChecked();
// Baseline product count test
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.total);
// Verify that all products are sorted on creepiness
expect(await confirmSorted(page)).toBe(true);
// Test search filtering for "percy": there should be two products
const searchBar = page.locator(qs.searchBar);
await searchBar.type("percy");
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.percy);
// And we should be back to the original number when clearing search.
await page.click(qs.clearSearch);
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.total);
// Click through to the Health & Exercise category and
// verify the correct number of products show up.
await page.click(qs.healthCategory);
activeCategory = page.locator(qs.activeCategory);
await expect(activeCategory).toHaveAttribute(
`data-name`,
`Health & Exercise`
);
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.health);
// Select a known subcategory and verify the correct
// number of products show up.
subcats = page.locator(`a.subcategories`);
await expect(subcats).toHaveCount(3);
subcat = page.locator(`a.subcategories:nth-child(2)`);
await expect(subcat).toHaveText(`Smart Scales`);
await page.click(`a.subcategories:nth-child(2)`);
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.smart);
// Filter for "percy" products: there should be two, and the active
// category should be "all" while search filtering
await searchBar.type("percy");
products = page.locator(qs.products);
await expect(products).toHaveCount(2);
activeCategory = page.locator(qs.activeCategory);
await expect(activeCategory).toHaveAttribute(`data-name`, `None`);
// Clear the search bar: original subcategory should be "active"
await page.click(qs.clearSearch);
activeCategory = page.locator(qs.activeCategory);
await expect(activeCategory).toHaveAttribute(
`data-name`,
`Health & Exercise`
);
activeSubCat = page.locator(qs.activeSubCategory);
await expect(activeSubCat).toHaveText(`Smart Scales`);
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.smart);
// Clicking the subcategory should restore the parent category
await page.click(qs.activeSubCategory);
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.health);
// Filtering for "ding" should refocus on text field if there
// was a search term, while filtering for ding-only
await searchBar.type("the");
await page.click(`main`);
await page.click(qs.dingLabel);
await expect(searchBar).toBeFocused();
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.theWithDing);
await page.click(qs.dingLabel);
products = page.locator(qs.products);
await expect(products).toHaveCount(counts.the);
// Finally, verify that all products are still sorted
await page.click(qs.clearSearch);
expect(await confirmSorted(page)).toBe(true);
});
/**
* Helper function for PNI search/filter tests.
* Confirms whether or not all products are sorted by creepiness
* @param {Page} page The Playwright page handle
*/
async function confirmSorted(page) {
return page.evaluate(() => {
const products = [...document.querySelectorAll(`.product-box`)];
return products.every((e, i, list, a, b) => {
if (i === 0) return true;
a = parseFloat(list[i - 1].dataset.creepiness);
b = parseFloat(e.dataset.creepiness);
return b >= a;
});
});
}