blob: 8ab7ff67c69cc915be6bced31d80cf91f9b0603d [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '/strings.m.js';
import './header.js';
import './loading_state.js';
import './new_column_selector.js';
import './product_selector.js';
import './table.js';
import './horizontal_carousel.js';
import './comparison_table_list.js';
import 'chrome://resources/cr_elements/cr_hidden_style.css.js';
import 'chrome://resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import './shared_vars.css.js';
import {ColorChangeUpdater} from 'chrome://resources/cr_components/color_change_listener/colors_css_updater.js';
import type {PageCallbackRouter} from 'chrome://resources/cr_components/commerce/product_specifications.mojom-webui.ts';
import type {ProductSpecificationsBrowserProxy} from 'chrome://resources/cr_components/commerce/product_specifications_browser_proxy.js';
import {ProductSpecificationsBrowserProxyImpl} from 'chrome://resources/cr_components/commerce/product_specifications_browser_proxy.js';
import type {ProductInfo, ProductSpecificationsSet} from 'chrome://resources/cr_components/commerce/shared.mojom-webui.js';
import {UserFeedback} from 'chrome://resources/cr_components/commerce/shopping_service.mojom-webui.js';
import type {ProductSpecificationsFeatureState} from 'chrome://resources/cr_components/commerce/shopping_service.mojom-webui.js';
import type {ShoppingServiceBrowserProxy} from 'chrome://resources/cr_components/commerce/shopping_service_browser_proxy.js';
import {ShoppingServiceBrowserProxyImpl} from 'chrome://resources/cr_components/commerce/shopping_service_browser_proxy.js';
import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js';
import {CrFeedbackOption} from 'chrome://resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import type {CrToastElement} from 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {OpenWindowProxyImpl} from 'chrome://resources/js/open_window_proxy.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {Uuid} from 'chrome://resources/mojo/mojo/public/mojom/base/uuid.mojom-webui.js';
import type {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';
import {getCss} from './app.css.js';
import {getHtml} from './app.html.js';
import type {ComparisonTableListElement} from './comparison_table_list.js';
import type {ComparisonTableListItemClickEvent, ComparisonTableListItemRenameEvent} from './comparison_table_list_item.js';
import type {HeaderElement} from './header.js';
import type {NewColumnSelectorElement} from './new_column_selector.js';
import {SectionType} from './product_selection_menu.js';
import type {ProductSelectorElement} from './product_selector.js';
import {Router} from './router.js';
import type {ProductSpecifications, ProductSpecificationsProduct} from './shopping_service.mojom-webui.js';
import type {ProductDetail, TableColumn, TableElement} from './table.js';
import {isValidLowercaseUuid} from './utils.js';
import {WindowProxy} from './window_proxy.js';
interface AggregatedProductData {
productInfo: ProductInfo|null;
spec: ProductSpecificationsProduct|null;
}
interface LoadingState {
loading: boolean;
urlCount: number;
}
export interface ProductSpecificationsElement {
$: {
comparisonTableList: ComparisonTableListElement,
contentContainer: HTMLElement,
empty: HTMLElement,
error: HTMLElement,
errorToast: CrToastElement,
header: HeaderElement,
loading: HTMLElement,
newColumnSelector: NewColumnSelectorElement,
offlineToast: CrToastElement,
productSelector: ProductSelectorElement,
specs: HTMLElement,
summaryContainer: HTMLElement,
summaryTable: TableElement,
syncPromo: HTMLElement,
turnOnSyncButton: CrButtonElement,
};
}
// This enum is used for metrics and should be kept in sync with the enum of
// the same name in enums.xml.
export enum CompareTableColumnAction {
REMOVE = 0,
CHANGE_ORDER_DRAG_AND_DROP = 1,
ADD_FROM_SUGGESTED = 2,
UPDATE_FROM_SUGGESTED = 3,
ADD_FROM_RECENTLY_VIEWED = 4,
UPDATE_FROM_RECENTLY_VIEWED = 5,
// Must be last:
MAX_VALUE = 6,
}
// This enum is used for metrics and should be kept in sync with the enum of
// the same name in enums.xml.
export enum CompareTableLoadStatus {
SUCCESS = 0,
FAILURE = 1,
FAILURE_EMPTY_TABLE_BACKEND = 2,
FAILURE_EMPTY_TABLE_NON_PRODUCTS = 2,
FAILURE_USER_NOT_ELIGIBLE = 3,
FAILURE_OFFLINE = 4,
// Must be last:
MAX_VALUE = 5,
}
export const COLUMN_MODIFICATION_HISTOGRAM_NAME: string =
'Commerce.Compare.Table.ColumnModification';
export const TABLE_LOAD_HISTOGRAM_NAME: string =
'Commerce.Compare.Table.LoadStatus';
export enum AppState {
ERROR = 0,
TABLE_EMPTY = 1,
SYNC_SCREEN = 2,
TABLE_POPULATED = 3,
LOADING = 4,
NO_CONTENT = 5,
}
function getProductDetails(
product: ProductSpecificationsProduct|null,
productSpecs: ProductSpecifications,
productInfo: ProductInfo|null): ProductDetail[] {
const productDetails: ProductDetail[] = [];
// First add rows that don't come directly from the product specifications
// backend. This includes the current price and buying options URL.
const priceRowTitle = loadTimeData.getString('priceRowTitle');
const price = productInfo?.priceSummary || productInfo?.currentPrice || '';
const jackpotUrl = product?.buyingOptionsUrl.url || '';
if (price || jackpotUrl) {
productDetails.push({
title: priceRowTitle,
content: {
price,
jackpotUrl,
},
});
} else {
// Show a dash if we don't have the price or buying options.
productDetails.push({
title: priceRowTitle,
content: null,
});
}
// The second row is the product-level summary.
productDetails.push({
title: loadTimeData.getString('productSummaryRowTitle'),
content: {
attributes: [],
summary: product?.summary || [],
},
});
productSpecs.productDimensionMap.forEach((title: string, key: bigint) => {
if (!product) {
// Fill in missing product details to ensure uniform table row count.
productDetails.push({title, content: null});
} else {
const value = product.productDimensionValues.get(key);
const attributes =
(value?.specificationDescriptions || []).flatMap(description => {
return {
label: description.label,
value: description.options.flatMap(option => option.descriptions)
.flatMap(desc => desc.text)
.join(', '),
};
}) ||
[];
const summary = value?.summary || [];
productDetails.push({
title,
content: {
attributes,
summary,
},
});
}
});
return productDetails;
}
function areStatesEqual(
firstState: ProductSpecificationsFeatureState|null,
secondState: ProductSpecificationsFeatureState|null) {
return firstState !== null && secondState !== null &&
firstState.isSyncingTabCompare === secondState.isSyncingTabCompare &&
firstState.canLoadFullPageUi === secondState.canLoadFullPageUi &&
firstState.canManageSets === secondState.canManageSets &&
firstState.canFetchData === secondState.canFetchData &&
firstState.isAllowedForEnterprise === secondState.isAllowedForEnterprise;
}
function findProductInResults(clusterId: bigint, specs: ProductSpecifications):
ProductSpecificationsProduct|null {
if (!specs) {
return null;
}
for (const product of specs.products) {
if (product.productClusterId.toString() === clusterId.toString()) {
return product;
}
}
return null;
}
// Custom event types for the start and end of the loading animation.
export const LOADING_START_EVENT_TYPE: string = 'loading-animation-start';
export const LOADING_END_EVENT_TYPE: string = 'loading-animation-end';
const LOADING_ANIMATION_SLIDE_PX = 16;
export class ProductSpecificationsElement extends CrLitElement {
static get is() {
return 'product-specifications-app';
}
static override get styles() {
return getCss();
}
static override get properties() {
return {
appState_: {type: Object},
id_: {type: Object},
sets_: {type: Array},
loadingState_: {type: Object},
productSpecificationsFeatureState_: {type: Object},
setName_: {type: String},
showComparisonTableList_: {type: Boolean},
showEmptyState_: {type: Boolean},
showTableDataUnavailableContainer_: {
type: Boolean,
reflect: true,
},
tableColumns_: {type: Object},
};
}
protected accessor appState_: AppState = AppState.NO_CONTENT;
protected accessor id_: Uuid|null = null;
protected accessor loadingState_:
LoadingState = {loading: false, urlCount: 0};
protected accessor productSpecificationsFeatureState_:
ProductSpecificationsFeatureState|null = null;
protected accessor setName_: string|null = null;
protected accessor sets_: ProductSpecificationsSet[] = [];
protected accessor showComparisonTableList_: boolean = false;
private accessor showEmptyState_: boolean = false;
protected accessor showTableDataUnavailableContainer_: boolean = false;
protected accessor tableColumns_: TableColumn[] = [];
private callbackRouter_: PageCallbackRouter;
private eventTracker_: EventTracker = new EventTracker();
private isWindowFocused_: boolean = true;
private listenerIds_: number[] = [];
private loadingAnimationSlideDurationMs_: number = 200;
private contentContainerIsHidden_: boolean = false;
private minLoadingAnimationMs_: number = 500;
private pendingSetUpdate_: (() => void)|null = null;
private productSpecificationsProxy_: ProductSpecificationsBrowserProxy =
ProductSpecificationsBrowserProxyImpl.getInstance();
private shoppingApi_: ShoppingServiceBrowserProxy =
ShoppingServiceBrowserProxyImpl.getInstance();
constructor() {
super();
this.callbackRouter_ = this.productSpecificationsProxy_.getCallbackRouter();
ColorChangeUpdater.forDocument().start();
}
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedPrivateProperties.has('productSpecificationsFeatureState_') ||
changedPrivateProperties.has('loadingState_') ||
changedPrivateProperties.has('showEmptyState_')) {
this.appState_ = this.computeAppState_();
}
if (changedPrivateProperties.has('appState_') ||
changedPrivateProperties.has('id_') ||
changedPrivateProperties.has('sets_') ||
changedPrivateProperties.has('showEmptyState_')) {
this.showComparisonTableList_ = this.computeShowComparisonTableList_();
}
if (changedPrivateProperties.has('appState_')) {
this.showTableDataUnavailableContainer_ =
this.computeShowTableDataUnavailableContainer_();
}
}
override render() {
return getHtml.bind(this)();
}
override async connectedCallback() {
super.connectedCallback();
this.listenerIds_.push(
this.callbackRouter_.onProductSpecificationsSetAdded.addListener(
(set: ProductSpecificationsSet) => this.onSetAdded_(set)),
this.callbackRouter_.onProductSpecificationsSetRemoved.addListener(
(uuid: Uuid) => this.onSetRemoved_(uuid)),
this.callbackRouter_.onProductSpecificationsSetUpdated.addListener(
(set: ProductSpecificationsSet) => this.onSetUpdated_(set)),
this.callbackRouter_.onSyncStateChanged.addListener(
() => this.updateFeatureState_()));
window.addEventListener('focus', () => {
this.isWindowFocused_ = true;
if (this.pendingSetUpdate_) {
this.pendingSetUpdate_();
}
// If there is a set update, the new set will be fetched when the table
// is reloaded.
this.pendingSetUpdate_ = null;
});
window.addEventListener('blur', () => {
this.isWindowFocused_ = false;
});
window.addEventListener('popstate', () => {
// Since we are modifying the browser's history with pushState, navigating
// forward or backward will display but not load the URL associated with a
// history entry. This forces the URL to be loaded.
window.location.replace(window.location.href);
});
this.eventTracker_.add(
this, 'click',
() => {
this.$.offlineToast.hide();
this.$.errorToast.hide();
},
/*useCapture=*/ true);
this.eventTracker_.add(window, 'online', () => {
this.$.offlineToast.hide();
});
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
await this.updateFeatureState_();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.listenerIds_.forEach(id => this.callbackRouter_.removeListener(id));
this.eventTracker_.removeAll();
}
resetLoadingAnimationMsForTesting(loadingAnimationMs: number = 0) {
this.minLoadingAnimationMs_ = loadingAnimationMs;
this.loadingAnimationSlideDurationMs_ = 0;
}
focusWindowForTesting() {
this.isWindowFocused_ = true;
}
private async loadTable_(state: ProductSpecificationsFeatureState) {
// Don't load the table if access conditions are not met.
if (!(state.isSyncingTabCompare && state.canLoadFullPageUi &&
state.canFetchData && state.isAllowedForEnterprise)) {
return;
}
const router = Router.getInstance();
const params = new URLSearchParams(router.getCurrentQuery());
const idParam = params.get('id');
if (idParam && isValidLowercaseUuid(idParam)) {
if (await this.loadSet_({value: idParam})) {
return;
}
}
const urlsParam = params.get('urls');
if (!urlsParam) {
this.updateEmptyState_(true);
return;
}
let urls: string[] = [];
try {
urls = JSON.parse(urlsParam);
} catch (_) {
return;
}
// TODO(b/346601645): Detect if a set already exists
await this.createNewSet_(urls);
}
private async updateFeatureState_() {
const {state} =
await this.shoppingApi_.getProductSpecificationsFeatureState();
if (!state) {
return;
}
if (!this.productSpecificationsFeatureState_ ||
!areStatesEqual(state, this.productSpecificationsFeatureState_)) {
await this.loadTable_(state);
this.productSpecificationsFeatureState_ = state;
}
}
private computeAppState_() {
if (this.productSpecificationsFeatureState_) {
if (!this.productSpecificationsFeatureState_.isSyncingTabCompare) {
return AppState.SYNC_SCREEN;
}
if (!(this.productSpecificationsFeatureState_.canLoadFullPageUi &&
this.productSpecificationsFeatureState_.canFetchData &&
this.productSpecificationsFeatureState_.isAllowedForEnterprise)) {
chrome.metricsPrivate.recordEnumerationValue(
TABLE_LOAD_HISTOGRAM_NAME,
CompareTableLoadStatus.FAILURE_USER_NOT_ELIGIBLE,
CompareTableLoadStatus.MAX_VALUE);
return AppState.ERROR;
}
if (this.loadingState_.loading) {
return AppState.LOADING;
}
if (this.showEmptyState_) {
return AppState.TABLE_EMPTY;
}
return AppState.TABLE_POPULATED;
}
if (this.isOffline_) {
chrome.metricsPrivate.recordEnumerationValue(
TABLE_LOAD_HISTOGRAM_NAME, CompareTableLoadStatus.FAILURE_OFFLINE,
CompareTableLoadStatus.MAX_VALUE);
return AppState.ERROR;
}
return AppState.NO_CONTENT;
}
private computeShowTableDataUnavailableContainer_() {
return this.appState_ === AppState.ERROR ||
this.appState_ === AppState.TABLE_EMPTY ||
this.appState_ === AppState.SYNC_SCREEN;
}
private computeShowComparisonTableList_() {
return this.showEmptyState_ && this.id_ === null && this.sets_.length > 0 &&
this.appState_ === AppState.TABLE_EMPTY;
}
protected isHeaderEnabled_() {
return !this.loadingState_.loading && this.appState_ !== AppState.ERROR &&
this.appState_ !== AppState.SYNC_SCREEN;
}
protected canShowFooter_(
showTableDataUnavailableContainer: boolean, appState: AppState) {
return !(
showTableDataUnavailableContainer || appState === AppState.NO_CONTENT);
}
protected canShowFeedbackButtons_() {
return Boolean(
this.productSpecificationsFeatureState_?.isQualityLoggingAllowed);
}
protected showSyncSetupFlow_() {
assert(this.productSpecificationsFeatureState_);
assert(!this.productSpecificationsFeatureState_.isSyncingTabCompare);
// If user's already signed in at the account level, then user needs to turn
// on the compare-specific sync from settings.
if (this.productSpecificationsFeatureState_.isSignedIn) {
OpenWindowProxyImpl.getInstance().openUrl(
'chrome://settings/syncSetup/advanced');
return;
}
this.productSpecificationsProxy_.showSyncSetupFlow();
}
protected showOfflineToast_() {
this.$.offlineToast.show();
}
private async populateTable_(urls: string[]) {
this.$.errorToast.hide();
// Transition directly to the empty state if there are no URLs.
if (urls.length === 0) {
this.tableColumns_ = [];
this.updateEmptyState_(true);
await this.showContentContainer_();
return;
}
await this.enterLoadingState_(urls.length);
const start = Date.now();
this.updateEmptyState_(false);
const tableColumns: TableColumn[] = [];
const {productSpecs} =
await this.shoppingApi_.getProductSpecificationsForUrls(
urls.map(url => ({url})));
const aggregatedDataByUrl =
await this.aggregateProductDataByUrl_(urls, productSpecs);
// Since it's possible we need the titles from an async source, fetch them
// before building the column list. Mapping the URLs with an async function
// runs the risk of the tasks finishing out of order and displaying the
// table incorrectly.
const titleMap: Map<string, string> = new Map();
await Promise.all(urls.map(async (url) => {
const info = aggregatedDataByUrl.get(url)?.productInfo;
const product = aggregatedDataByUrl.get(url)?.spec;
const title = product?.title || info?.title ||
(await this.productSpecificationsProxy_.getPageTitleFromHistory(
{url}))
.title;
titleMap.set(url, title);
}));
urls.map((url: string, index: number) => {
const info = aggregatedDataByUrl.get(url)?.productInfo;
const product = aggregatedDataByUrl.get(url)?.spec;
tableColumns[index] = {
selectedItem: {
title: titleMap.get(url) || '',
url: url,
imageUrl: info?.imageUrl?.url || product?.imageUrl?.url || '',
},
productDetails:
getProductDetails(product || null, productSpecs, info || null),
};
});
// Show an error message if we didn't get back any dimensions. Note that
// the URLs in the comparison will still be displayed as columns.
if (productSpecs.productDimensionMap.size === 0 && urls.length > 1) {
this.$.errorToast.show();
// If there's no product info for any of the URLs, the table is a
// collection of non-products.
if (urls.some((url) => !!aggregatedDataByUrl.get(url))) {
chrome.metricsPrivate.recordEnumerationValue(
TABLE_LOAD_HISTOGRAM_NAME,
CompareTableLoadStatus.FAILURE_EMPTY_TABLE_NON_PRODUCTS,
CompareTableLoadStatus.MAX_VALUE);
} else {
chrome.metricsPrivate.recordEnumerationValue(
TABLE_LOAD_HISTOGRAM_NAME,
CompareTableLoadStatus.FAILURE_EMPTY_TABLE_BACKEND,
CompareTableLoadStatus.MAX_VALUE);
}
} else {
chrome.metricsPrivate.recordEnumerationValue(
TABLE_LOAD_HISTOGRAM_NAME, CompareTableLoadStatus.SUCCESS,
CompareTableLoadStatus.MAX_VALUE);
}
// Enforce a minimum amount of time in the loading state to avoid it
// appearing like an unintentional flash.
const delta = Date.now() - start;
if (delta < this.minLoadingAnimationMs_ && urls.length > 0) {
await new Promise(
res => setTimeout(res, this.minLoadingAnimationMs_ - delta));
}
this.tableColumns_ = tableColumns;
this.updateEmptyState_(this.tableColumns_.length === 0);
this.exitLoadingState_();
}
private get isOffline_(): boolean {
return !WindowProxy.getInstance().onLine;
}
private async getProductInfoForUrls_(urls: string[]):
Promise<Map<string, ProductInfo>> {
const urlList: Url[] = urls.map((url) => ({url}));
const {productInfos} =
await this.shoppingApi_.getProductInfoForUrls(urlList);
const infoMap: Map<string, ProductInfo> = new Map();
for (const info of productInfos) {
if (info && info.clusterId) {
infoMap.set(info.productUrl.url, info);
}
}
return infoMap;
}
private async aggregateProductDataByUrl_(
urls: string[], specs: ProductSpecifications):
Promise<Map<string, AggregatedProductData>> {
const urlToProductInfoMap: Map<string, ProductInfo> =
await this.getProductInfoForUrls_(urls);
const specProductMap: Map<string, ProductSpecificationsProduct> = new Map();
urlToProductInfoMap.forEach((value, key) => {
const product = findProductInResults(value.clusterId, specs);
if (product) {
specProductMap.set(key, product);
}
});
const aggregatedDatas: Map<string, AggregatedProductData> = new Map();
urls.forEach((url) => {
const productInfo = urlToProductInfoMap.get(url);
const productSpecs = specProductMap.get(url);
aggregatedDatas.set(url, {
productInfo: productInfo ?? null,
spec: productSpecs ?? null,
});
});
return aggregatedDatas;
}
private async loadSet_(uuid: Uuid): Promise<boolean> {
const {set} =
await this.shoppingApi_.getProductSpecificationsSetByUuid(uuid);
if (set) {
const {disclosureShown} =
await this.productSpecificationsProxy_.maybeShowDisclosure(
/* urls= */[], /* name= */ '', uuid.value);
if (disclosureShown) {
this.updateEmptyState_(true);
this.id_ = null;
return false;
}
// Hide the content container if transitioning from the empty state. We
// will only show the loading state later if the set has at least one URL.
// The comparison table list will be hidden once the table ID is set, so
// we hide the content container first.
if (this.appState_ === AppState.TABLE_EMPTY) {
await this.hideContentContainer_();
}
this.id_ = set.uuid;
document.title = set.name;
this.setName_ = set.name;
this.populateTable_(set.urls.map(url => (url.url)));
return true;
}
this.updateEmptyState_(true);
this.id_ = null;
return false;
}
private deleteSet_(uuid: Uuid|null = this.id_) {
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
if (uuid) {
this.shoppingApi_.deleteProductSpecificationsSet(uuid);
}
}
protected updateSetName_(e: CustomEvent<{name: string}>) {
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
if (this.id_) {
this.shoppingApi_.setNameForProductSpecificationsSet(
this.id_, e.detail.name);
}
}
protected seeAllSets_() {
this.productSpecificationsProxy_.showComparePage(true);
}
protected async onUrlAdd_(
e: CustomEvent<{url: string, urlSection: SectionType}>) {
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
let recordValue = CompareTableColumnAction.MAX_VALUE;
switch (e.detail.urlSection) {
case SectionType.RECENT:
recordValue = CompareTableColumnAction.ADD_FROM_RECENTLY_VIEWED;
break;
case SectionType.SUGGESTED:
recordValue = CompareTableColumnAction.ADD_FROM_SUGGESTED;
break;
}
chrome.metricsPrivate.recordEnumerationValue(
COLUMN_MODIFICATION_HISTOGRAM_NAME, recordValue,
CompareTableColumnAction.MAX_VALUE);
const urls = this.getTableUrls_();
urls.push(e.detail.url);
// If there is already a current set, we won't be showing the disclosure and
// we can modify the set directly; otherwise, user is trying to add a url
// from empty state, and we'll try to show the disclosure.
if (this.id_) {
this.modifyUrls_(urls);
return;
}
const {disclosureShown} =
await this.productSpecificationsProxy_.maybeShowDisclosure(
urls.map(url => ({url})), this.setName_ ? this.setName_ : '',
/* set_id= */ '');
// If the disclosure is shown, we won't update the current set.
if (!disclosureShown) {
this.modifyUrls_(urls);
}
}
protected onUrlChange_(
e: CustomEvent<{url: string, urlSection: SectionType, index: number}>) {
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
let recordValue = CompareTableColumnAction.MAX_VALUE;
switch (e.detail.urlSection) {
case SectionType.RECENT:
recordValue = CompareTableColumnAction.UPDATE_FROM_RECENTLY_VIEWED;
break;
case SectionType.SUGGESTED:
recordValue = CompareTableColumnAction.UPDATE_FROM_SUGGESTED;
break;
}
chrome.metricsPrivate.recordEnumerationValue(
COLUMN_MODIFICATION_HISTOGRAM_NAME, recordValue,
CompareTableColumnAction.MAX_VALUE);
const urls = this.getTableUrls_();
urls[e.detail.index] = e.detail.url;
this.modifyUrls_(urls);
}
protected onUrlOrderUpdate_() {
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
chrome.metricsPrivate.recordEnumerationValue(
COLUMN_MODIFICATION_HISTOGRAM_NAME,
CompareTableColumnAction.CHANGE_ORDER_DRAG_AND_DROP,
CompareTableColumnAction.MAX_VALUE);
const urls = this.getTableUrls_();
this.modifyUrls_(urls);
}
protected onUrlRemove_(e: CustomEvent<{index: number}>) {
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
chrome.metricsPrivate.recordEnumerationValue(
COLUMN_MODIFICATION_HISTOGRAM_NAME, CompareTableColumnAction.REMOVE,
CompareTableColumnAction.MAX_VALUE);
const urls = this.getTableUrls_();
urls.splice(e.detail.index, 1);
this.modifyUrls_(urls);
}
private modifyUrls_(urls: string[]) {
if (this.id_) {
this.shoppingApi_.setUrlsForProductSpecificationsSet(
this.id_, urls.map(url => ({url})));
} else {
this.createNewSet_(urls);
}
}
private async createNewSet_(urls: string[]) {
if (this.isOffline_) {
this.showOfflineToast_();
return;
}
assert(!this.id_ && !this.setName_);
this.setName_ = loadTimeData.getString('defaultTableTitle');
const {createdSet} = await this.shoppingApi_.addProductSpecificationsSet(
this.setName_, urls.map(url => ({url})));
if (createdSet) {
this.id_ = createdSet.uuid;
document.title = this.setName_;
window.history.pushState(undefined, '', `?id=${this.id_.value}`);
}
this.populateTable_(urls);
}
protected getTableUrls_(): string[] {
return this.tableColumns_.map(
(column: TableColumn) => column.selectedItem.url);
}
protected isTableFull_(): boolean {
return this.tableColumns_.length >= loadTimeData.getInteger('maxTableSize');
}
private onSetUpdated_(set: ProductSpecificationsSet) {
// If the page does not have focus, schedule the update for later in case a
// newer update is received before the tab is focused. This prevents all
// updates from triggering at the same time, which may cause a flicker.
if (!this.isWindowFocused_) {
this.pendingSetUpdate_ = this.updateSet_.bind(this, set);
return;
}
this.updateSet_(set);
}
private async updateSet_(set: ProductSpecificationsSet) {
if (this.showEmptyState_) {
const tableIndex =
this.sets_.findIndex(table => table.uuid.value === set.uuid.value);
if (tableIndex !== -1) {
this.sets_ = this.sets_.toSpliced(tableIndex, 1, set);
}
}
if (set.uuid.value !== this.id_?.value) {
return;
}
document.title = set.name;
this.setName_ = set.name;
let urlSetChanged = false;
let orderChanged = false;
const tableUrls = this.getTableUrls_();
if (tableUrls.length === set.urls.length) {
for (const [i, setUrl] of set.urls.entries()) {
if (setUrl.url !== tableUrls[i]) {
orderChanged = true;
}
if (!tableUrls.includes(setUrl.url)) {
urlSetChanged = true;
break;
}
}
} else {
urlSetChanged = true;
}
if (urlSetChanged) {
this.closeAllProductSelectionMenus_();
// Hide the content container as we might transition directly to the
// empty state if there are no URLs left.
await this.hideContentContainer_();
this.populateTable_(set.urls.map(url => url.url));
} else if (orderChanged) {
const newCols: TableColumn[] = [];
for (const [_, setUrl] of set.urls.entries()) {
const existingIndex = tableUrls.indexOf(setUrl.url);
assert(existingIndex >= 0, 'Did not find column to reorder!');
const col = this.tableColumns_[existingIndex];
assert(col);
newCols.push(col);
}
this.tableColumns_ = newCols;
}
}
private onSetRemoved_(id: Uuid) {
if (id.value === this.id_?.value) {
window.location.replace(window.location.origin);
}
if (this.showEmptyState_) {
this.sets_ = this.sets_.filter(table => table.uuid.value !== id.value);
}
}
protected onFeedbackSelectedOptionChanged_(
e: CustomEvent<{value: CrFeedbackOption}>) {
switch (e.detail.value) {
case CrFeedbackOption.UNSPECIFIED:
this.shoppingApi_.setProductSpecificationsUserFeedback(
UserFeedback.kUnspecified);
return;
case CrFeedbackOption.THUMBS_UP:
this.shoppingApi_.setProductSpecificationsUserFeedback(
UserFeedback.kThumbsUp);
return;
case CrFeedbackOption.THUMBS_DOWN:
this.shoppingApi_.setProductSpecificationsUserFeedback(
UserFeedback.kThumbsDown);
return;
}
}
private onSetAdded_(set: ProductSpecificationsSet) {
if (this.showEmptyState_) {
this.sets_ = [set].concat(this.sets_);
}
}
protected getDisclaimerText_(): string {
return loadTimeData.getStringF(
'experimentalFeatureDisclaimer', loadTimeData.getString('userEmail'));
}
// Hide the content container with an animation if not already hidden.
private async hideContentContainer_() {
if (this.contentContainerIsHidden_) {
return;
}
this.contentContainerIsHidden_ = true;
await this.$.contentContainer
.animate(
[
{opacity: 1, transform: 'translateY(0px)'},
{
opacity: 0,
transform: `translateY(-${LOADING_ANIMATION_SLIDE_PX}px)`,
},
],
{
duration: this.loadingAnimationSlideDurationMs_,
easing: 'ease-out',
fill: 'forwards',
})
.finished;
}
// Show the content container with an animation if not already shown.
private async showContentContainer_() {
if (!this.contentContainerIsHidden_) {
return;
}
await this.$.contentContainer
.animate(
[
{
opacity: 0,
transform: `translateY(${LOADING_ANIMATION_SLIDE_PX}px)`,
},
{opacity: 1, transform: 'translateY(0px)'},
],
{
duration: this.loadingAnimationSlideDurationMs_,
easing: 'ease-out',
fill: 'forwards',
})
.finished;
this.contentContainerIsHidden_ = false;
}
// Resolves upon updating the loading state.
private async enterLoadingState_(urlCount: number): Promise<void> {
if ([
AppState.ERROR,
AppState.SYNC_SCREEN,
AppState.LOADING,
AppState.NO_CONTENT,
].includes(this.appState_)) {
this.loadingState_ = {loading: true, urlCount};
this.dispatchLoadingStartEvent_();
return Promise.resolve();
}
return new Promise<void>(async resolve => {
await this.hideContentContainer_();
this.loadingState_ = {loading: true, urlCount};
resolve();
await this.showContentContainer_();
this.dispatchLoadingStartEvent_();
});
}
private async exitLoadingState_() {
await this.hideContentContainer_();
this.loadingState_ = {loading: false, urlCount: 0};
await this.showContentContainer_();
this.dispatchLoadingEndEvent_();
}
private dispatchLoadingStartEvent_() {
this.fire(LOADING_START_EVENT_TYPE);
}
private dispatchLoadingEndEvent_() {
this.fire(LOADING_END_EVENT_TYPE);
}
private async updateEmptyState_(shouldShow: boolean) {
this.showEmptyState_ = shouldShow;
this.requestUpdate();
// If we show the empty state and there are no comparison tables, try to
// fetch them.
if (this.showEmptyState_ && this.sets_.length === 0) {
const {sets} = await this.shoppingApi_.getAllProductSpecificationsSets();
this.sets_ = sets;
}
}
protected onComparisonTableListItemClick_(
event: ComparisonTableListItemClickEvent) {
window.history.pushState(undefined, '', `?id=${event.detail.uuid.value}`);
this.loadSet_(event.detail.uuid);
}
protected onComparisonTableListItemRename_(
event: ComparisonTableListItemRenameEvent) {
this.shoppingApi_.setNameForProductSpecificationsSet(
event.detail.uuid, event.detail.name);
}
protected onHeaderMenuDeleteClick_() {
this.deleteSet_();
}
private closeAllProductSelectionMenus_() {
this.$.summaryTable.closeAllProductSelectionMenus();
this.$.newColumnSelector.closeMenu();
}
}
declare global {
interface HTMLElementTagNameMap {
'product-specifications-app': ProductSpecificationsElement;
}
}
customElements.define(
ProductSpecificationsElement.is, ProductSpecificationsElement);