From 315f397bcb914cd4e2d72527c39f6e41ee7b6df1 Mon Sep 17 00:00:00 2001 From: James Scott Date: Tue, 17 Mar 2026 21:39:12 +0000 Subject: [PATCH] refactor: improve type safety and revamp api client Refactored the frontend to use stricter typing and path-aware response handling. Replaced unsafe type assertions with type guards and updated the API client to handle specific status codes more robustly. - Reorganized api/client.ts to use status code handlers per endpoint - Added type guards for saved searches and vendor positions - Cleaned up manual casts and redundant checks across components - Fixed various linting and formatting issues to match project standards --- frontend/eslint.config.mjs | 18 + frontend/src/static/js/api/client.ts | 1005 ++++++++--------- frontend/src/static/js/api/errors.ts | 15 +- .../test/webstatus-feature-page.test.ts | 6 +- .../js/components/webstatus-columns-dialog.ts | 12 +- .../webstatus-feature-gone-split-page.ts | 18 +- .../js/components/webstatus-feature-page.ts | 33 +- .../webstatus-feature-usage-chart-panel.ts | 20 +- ...status-feature-wpt-progress-chart-panel.ts | 23 +- .../static/js/components/webstatus-gchart.ts | 35 +- .../components/webstatus-line-chart-panel.ts | 6 +- .../webstatus-line-chart-tabbed-panel.ts | 7 +- .../webstatus-notfound-error-page.ts | 3 +- .../js/components/webstatus-overview-cells.ts | 27 +- .../components/webstatus-overview-content.ts | 12 +- .../webstatus-overview-data-loader.ts | 25 +- .../components/webstatus-overview-filters.ts | 25 +- .../js/components/webstatus-overview-page.ts | 29 +- .../webstatus-overview-pagination.ts | 15 +- .../static/js/components/webstatus-page.ts | 24 +- .../webstatus-saved-search-controls.ts | 2 +- .../webstatus-saved-search-editor.ts | 16 +- .../js/components/webstatus-sidebar-menu.ts | 27 +- ...-stats-global-feature-count-chart-panel.ts | 17 +- ...atus-stats-missing-one-impl-chart-panel.ts | 18 +- .../js/components/webstatus-typeahead.ts | 21 +- .../js/contexts/app-bookmark-info-context.ts | 4 +- .../services/webstatus-bookmarks-service.ts | 31 +- frontend/src/static/js/utils/constants.ts | 6 + .../src/static/js/utils/vendor-position.ts | 24 +- 30 files changed, 757 insertions(+), 767 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index b6536a6e4..786d9371c 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -49,6 +49,11 @@ export default defineConfig([ '@typescript-eslint/space-before-function-paren': 'off', 'node/no-unpublished-import': ['off'], + // Strict Type Safety + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-type-assertion': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-unused-vars': [ 'error', { @@ -71,6 +76,15 @@ export default defineConfig([ // For CustomEvent. Remove once we upgrade to a LTS version of Node >= 22.1.0. 'n/no-unsupported-features/node-builtins': 'off', + '@typescript-eslint/no-restricted-types': [ + 'error', + { + types: { + unknown: + 'Prefer explicit types or type guards instead of unknown. Use unknown only in tests or where absolutely unavoidable.', + }, + }, + ], }, }, { @@ -79,6 +93,10 @@ export default defineConfig([ rules: { '@typescript-eslint/no-unused-expressions': 'off', '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-type-assertion': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + '@typescript-eslint/no-restricted-types': 'off', }, }, ]); diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts index 0470929a2..a43c0b262 100644 --- a/frontend/src/static/js/api/client.ts +++ b/frontend/src/static/js/api/client.ts @@ -14,12 +14,8 @@ * limitations under the License. */ -import createClient, { - HeadersOptions, - type FetchOptions, - ParamsOption, - ParseAsResponse, -} from 'openapi-fetch'; +import createClient, {type FetchOptions} from 'openapi-fetch'; + import {type components, type paths} from 'webstatus.dev-backend'; import { createAPIError, @@ -27,12 +23,7 @@ import { FeatureMovedError, } from './errors.js'; -import { - MediaType, - SuccessResponse, - ResponseObjectMap, - FilterKeys, -} from 'openapi-typescript-helpers'; +import {FilterKeys} from 'openapi-typescript-helpers'; export type FeatureSortOrderType = NonNullable< paths['/v1/features']['get']['parameters']['query'] @@ -51,31 +42,72 @@ export type FeatureWPTMetricViewType = Exclude< export type BrowsersParameter = components['parameters']['browserPathParam']; +/** + * Union of API paths that support pagination. + * We explicitly list these to enable path-aware type inference for paginated data. + */ type PageablePath = | '/v1/features' | '/v1/features/{feature_id}/stats/wpt/browsers/{browser}/channels/{channel}/{metric_view}' + | '/v1/features/{feature_id}/stats/usage/chrome/daily_stats' | '/v1/users/me/notification-channels' | '/v1/stats/features/browsers/{browser}/feature_counts' | '/v1/users/me/saved-searches' | '/v1/users/me/subscriptions' - | '/v1/stats/baseline_status/low_date_feature_counts'; - -type SuccessResponsePageableData< - T, - Options, - Media extends MediaType, - Path extends PageablePath, -> = ParseAsResponse< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - SuccessResponse & Record, Media>, - Options -> & { - metadata: Path extends '/v1/features' ? PageMetadataWithTotal : PageMetadata; -}; + | '/v1/stats/baseline_status/low_date_feature_counts' + | '/v1/stats/features/browsers/{browser}/missing_one_implementation_counts' + | '/v1/stats/features/browsers/{browser}/missing_one_implementation_counts/{date}/features'; + +/** + * Utility to extract the item type from a paginated API response. + * + * Uses 'infer' to automatically discover the array element type from the OpenAPI schema. + * If the path is not a valid paginated path, it resolves to 'never' to prevent unsafe usage. + */ +type PageItems = paths[Path]['get'] extends { + responses: { + 200: { + content: { + 'application/json': { + data: (infer U)[]; + }; + }; + }; + }; +} + ? U + : never; type PageMetadata = components['schemas']['PageMetadata']; type PageMetadataWithTotal = components['schemas']['PageMetadataWithTotal']; +export type AnyPageMetadata = PageMetadata & Partial; + +export type SuccessResponsePageableData = { + metadata: AnyPageMetadata; + data: PageItems[]; +}; + +type ResponsesObject< + Path extends keyof paths, + Method extends keyof paths[Path], +> = paths[Path][Method] extends {responses: infer R} ? R : never; + +/** + * Extracts the payload type for a specific status code of an API endpoint. + * This ensures that status code handlers receive the correctly typed data + * from the OpenAPI schema. + */ +type ResponsePayload< + Path extends keyof paths, + Method extends keyof paths[Path], + Status extends keyof ResponsesObject, +> = ResponsesObject[Status] extends { + content: {'application/json': infer T}; +} + ? T + : undefined; + type ManualOffsetPagination = (offset: number) => string; export type UpdateSavedSearchInput = { @@ -114,12 +146,7 @@ export const BROWSER_ID_TO_LABEL: Record = { /** Map from label to browser id */ export const BROWSER_LABEL_TO_ID: Record = - Object.fromEntries( - Object.entries(BROWSER_ID_TO_LABEL).map(([key, value]) => [ - value, - key as BrowsersParameter, - ]), - ); + Object.fromEntries(ALL_BROWSERS.map(key => [BROWSER_ID_TO_LABEL[key], key])); export const BROWSER_ID_TO_COLOR: Record = { @@ -168,6 +195,61 @@ export const SUBTEST_COUNT_METRIC_VIEW: components['schemas']['WPTMetricView'] = export const DEFAULT_TEST_VIEW: components['schemas']['WPTMetricView'] = TEST_COUNT_METRIC_VIEW; +export const DEFAULT_SORT_ORDER: FeatureSortOrderType = 'name_asc'; + +export function isWPTMetricViewType( + val: string | null | undefined, +): val is FeatureWPTMetricViewType { + return val === TEST_COUNT_METRIC_VIEW || val === SUBTEST_COUNT_METRIC_VIEW; +} + +export function isFeatureSortOrderType( + val: string | null | undefined, +): val is FeatureSortOrderType { + if (!val) return false; + return val.endsWith('_asc') || val.endsWith('_desc'); +} + +/** + * Type guard to verify if a response matches the expected paginated structure for a given path. + * + * This acts as a 'gatekeeper' between the raw API response and our typed internal logic, + * replacing the need for unsafe 'as' assertions. + */ +function isPageAtPath( + val: {} | null | undefined, + _path: Path, +): val is {metadata?: AnyPageMetadata; data?: PageItems[]} { + return ( + val !== null && + typeof val === 'object' && + // Not all endpoints return a 'data' field, but if it does exist it must be an array. + (('data' in val && Array.isArray(val.data)) || !Object.hasOwn(val, 'data')) + ); +} + +function isFeatureGoneError( + val: {} | null | undefined, +): val is components['schemas']['FeatureGoneError'] { + return ( + val !== null && + typeof val === 'object' && + 'new_features' in val && + Array.isArray(val.new_features) + ); +} + +function isFeature( + val: {} | null | undefined, +): val is components['schemas']['Feature'] { + return ( + val !== null && + typeof val === 'object' && + 'feature_id' in val && + 'name' in val + ); +} + export type WPTRunMetric = components['schemas']['WPTRunMetric']; export type WPTRunMetricsPage = components['schemas']['WPTRunMetricsPage']; export type ChromeUsageStat = components['schemas']['ChromeUsageStat']; @@ -187,15 +269,14 @@ export type MissingOneImplFeaturesList = components['schemas']['MissingOneImplFeature'][]; export type SavedSearchResponse = components['schemas']['SavedSearchResponse']; -// TODO. Remove once not behind UbP -const temporaryFetchOptions: FetchOptions = { - credentials: 'include', -}; +const fetchOptions = { + // TODO. Remove once not behind UbP + credentials: 'include' as const, + // https://github.com/drwpow/openapi-typescript/issues/1431 -// TODO. Remove once not behind UbP -// https://github.com/drwpow/openapi-typescript/issues/1431 -const temporaryHeaders: HeadersOptions = { - 'Content-Type': null, + headers: { + 'Content-Type': null, + }, }; // Create a base64 string that is URL safe. @@ -211,7 +292,7 @@ export class APIClient { constructor(baseUrl: string) { this.client = createClient({ baseUrl, - headers: temporaryHeaders, + ...fetchOptions, }); } @@ -225,22 +306,17 @@ export class APIClient { } /** - * Returns one page of data. + * Retrieves a single page of data with path-aware type inference. + * + * This method ensures that the returned data is perfectly synchronized with the OpenAPI + * schema for the specific path, eliminating the need for manual type casting. */ - public async getPageOfData< - Path extends PageablePath, - ResponseData extends SuccessResponsePageableData< - paths[PageablePath]['get'], - ParamsOption, - 'application/json', - Path - >, - >( + public async getPageOfData( path: Path, params: FetchOptions>, pageToken?: string, pageSize?: number, - ): Promise { + ): Promise> { // Add the pagination parameters to the query if (params.params === undefined) params.params = {}; if (params.params.query === undefined) params.params.query = {}; @@ -248,59 +324,140 @@ export class APIClient { params.params.query.page_token = pageToken; params.params.query.page_size = pageSize; - const options = { - ...temporaryFetchOptions, - ...params, - }; - const {data, error} = await this.client.GET(path, options); - - if (error !== undefined) { - throw createAPIError(error); - } + const result = await this.handleResponse( + this.client.GET(path, params), + path, + 'get', + ); - if (data === undefined) { - throw createAPIError(); + if (isPageAtPath(result, path)) { + return { + metadata: result.metadata ?? {}, + data: result.data ?? [], + }; } - return data as ResponseData; + throw createAPIError(new Error('Response data missing data array')); } /** Returns all pages of data. */ - public async getAllPagesOfData< - Path extends PageablePath, - ResponseData extends SuccessResponsePageableData< - paths[PageablePath]['get'], - ParamsOption, - 'application/json', - Path - >, - >( + public async getAllPagesOfData( path: Path, params: FetchOptions>, overridenOffsetPaginator?: ManualOffsetPagination, - ): Promise { + ): Promise[]> { let offset = 0; - let nextPageToken; - const allData: ResponseData['data'][number][] = []; + let nextPageToken: string | undefined = undefined; + const allData: PageItems[] = []; - do { - const page: ResponseData = await this.getPageOfData( + while (true) { + const page: SuccessResponsePageableData = await this.getPageOfData( path, params, - overridenOffsetPaginator - ? overridenOffsetPaginator(offset) - : nextPageToken, + nextPageToken || + (overridenOffsetPaginator && overridenOffsetPaginator(offset)), 100, ); - nextPageToken = page?.metadata?.next_page_token; - allData.push(...(page.data ?? [])); - offset += (page.data || []).length; - } while (nextPageToken !== undefined); + nextPageToken = page.metadata.next_page_token; + allData.push(...page.data); + offset += page.data.length; + if (nextPageToken === undefined) { + break; + } + } return allData; } + /** + * Returns an async iterable that yields pages of data. + */ + public async *getAsyncIterableOfData( + path: Path, + params: FetchOptions>, + pageSize?: number, + ): AsyncIterable[]> { + let nextPageToken: string | undefined = undefined; + while (true) { + const page: SuccessResponsePageableData = await this.getPageOfData( + path, + params, + nextPageToken, + pageSize, + ); + yield page.data; + nextPageToken = page.metadata.next_page_token; + if (nextPageToken === undefined) { + break; + } + } + } + + /** + * Type-safe error handler for any API response. + * Leverages openapi-fetch result types to ensure data extraction is safe and concise. + */ + private async handleResponse< + T, + ErrorType, + Path extends keyof paths, + Method extends keyof paths[Path] & string, + >( + promise: Promise<{ + data?: T; + error?: ErrorType; + response: Response; + }>, + _path: Path, + _method: Method, + options?: { + statusHandlers?: Partial<{ + [K in keyof ResponsesObject]: ( + payload: ResponsePayload, + data: T | undefined, + response: Response, + ) => void; + }>; + }, + ): Promise { + const result = await promise; + + if (options?.statusHandlers) { + // Use Object.entries to safely iterate without needing narrow type assertions. + for (const [code, handler] of Object.entries(options.statusHandlers)) { + if ( + Number(code) === result.response.status && + typeof handler === 'function' + ) { + const payload = + result.response.status >= 200 && result.response.status < 300 + ? result.data + : result.error; + handler.call( + options.statusHandlers, + payload, + result.data, + result.response, + ); + } + } + } + + if (result.error !== undefined) { + throw createAPIError(result.error, result.response.status); + } + if (!result.response.ok) { + throw createAPIError(undefined, result.response.status); + } + + // Now we know it's a success response, and openapi-fetch guarantees data matches the success type. + // In case of 204 No Content, result.data is undefined and T is void/undefined. + // We use a non-null assertion here to bridge the gap between the complex union type and + // the generic return type T, following the restriction on 'as' assertions. + return result.data!; + } + public async getFeature( featureId: string, wptMetricView: FeatureWPTMetricViewType, @@ -308,55 +465,48 @@ export class APIClient { const qsParams: paths['/v1/features/{feature_id}']['get']['parameters']['query'] = {}; if (wptMetricView) qsParams.wpt_metric_view = wptMetricView; - const resp = await this.client.GET('/v1/features/{feature_id}', { - ...temporaryFetchOptions, - params: { - path: {feature_id: featureId}, - query: qsParams, + return this.handleResponse( + this.client.GET('/v1/features/{feature_id}', { + params: { + path: {feature_id: featureId}, + query: qsParams, + }, + }), + '/v1/features/{feature_id}', + 'get', + { + statusHandlers: { + 200: (data, _d, response) => { + if (response.redirected && isFeature(data)) { + const newId = response.url.split('/').pop() || ''; + throw new FeatureMovedError('Redirected', newId, data); + } + }, + 410: error => { + if (isFeatureGoneError(error)) { + throw new FeatureGoneSplitError( + error.message, + error.new_features.map(f => f.id), + ); + } + }, + }, }, - }); - if (resp.error !== undefined) { - const data = resp.error; - if (resp.response.status === 410 && 'new_features' in data) { - // Type narrowing doesn't work. - // https://github.com/openapi-ts/openapi-typescript/issues/1723 - // We have to force it. - const featureGoneData = - data as components['schemas']['FeatureGoneError']; - throw new FeatureGoneSplitError( - resp.error.message, - featureGoneData.new_features.map(f => f.id), - ); - } - throw createAPIError(resp.error); - } - if (resp.response.redirected) { - const featureId = resp.response.url.split('/').pop() || ''; - throw new FeatureMovedError( - 'redirected to feature', - featureId, - resp.data, - ); - } - return resp.data; + ); } - public async getFeatureMetadata( + public getFeatureMetadata( featureId: string, ): Promise { - const {data, error} = await this.client.GET( - '/v1/features/{feature_id}/feature-metadata', - { - ...temporaryFetchOptions, + return this.handleResponse( + this.client.GET('/v1/features/{feature_id}/feature-metadata', { params: { path: {feature_id: featureId}, }, - }, + }), + '/v1/features/{feature_id}/feature-metadata', + 'get', ); - if (error !== undefined) { - throw createAPIError(error); - } - return data; } // Get one page of features @@ -366,7 +516,7 @@ export class APIClient { wptMetricView?: FeatureWPTMetricViewType, offset?: number, pageSize?: number, - ): Promise { + ): Promise> { const queryParams: paths['/v1/features']['get']['parameters']['query'] = {}; if (q) queryParams.q = q; if (sort) queryParams.sort = sort; @@ -393,10 +543,7 @@ export class APIClient { if (q) queryParams.q = q; if (sort) queryParams.sort = sort; if (wptMetricView) queryParams.wpt_metric_view = wptMetricView; - return this.getAllPagesOfData< - '/v1/features', - components['schemas']['FeaturePage'] - >( + return this.getAllPagesOfData( '/v1/features', {params: {query: queryParams}}, this.createOffsetPaginationTokenForGetFeatures, @@ -407,16 +554,7 @@ export class APIClient { public async getAllUserSavedSearches( token: string, ): Promise { - type SavedSearchResponsePage = SuccessResponsePageableData< - components['schemas']['SavedSearchResponse'][], - ParamsOption<'/v1/users/me/saved-searches'>, - 'application/json', - '/v1/users/me/saved-searches' - >; - return this.getAllPagesOfData< - '/v1/users/me/saved-searches', - SavedSearchResponsePage - >('/v1/users/me/saved-searches', { + return this.getAllPagesOfData('/v1/users/me/saved-searches', { headers: { Authorization: `Bearer ${token}`, }, @@ -426,19 +564,7 @@ export class APIClient { public async listNotificationChannels( token: string, ): Promise { - type NotificationChannelPage = SuccessResponsePageableData< - paths['/v1/users/me/notification-channels']['get'], - FetchOptions< - FilterKeys - >, - 'application/json', - '/v1/users/me/notification-channels' - >; - - return this.getAllPagesOfData< - '/v1/users/me/notification-channels', - NotificationChannelPage - >('/v1/users/me/notification-channels', { + return this.getAllPagesOfData('/v1/users/me/notification-channels', { headers: { Authorization: `Bearer ${token}`, }, @@ -449,24 +575,21 @@ export class APIClient { token: string, pingOptions?: {githubToken?: string}, ): Promise { - const options: FetchOptions< - FilterKeys - > = { - headers: { - Authorization: `Bearer ${token}`, - }, - credentials: temporaryFetchOptions.credentials, - body: { - github_token: pingOptions?.githubToken, - }, - }; - const {error} = await this.client.POST('/v1/users/me/ping', options); - if (error) { - throw createAPIError(error); - } + await this.handleResponse( + this.client.POST('/v1/users/me/ping', { + headers: { + Authorization: `Bearer ${token}`, + }, + body: { + github_token: pingOptions?.githubToken, + }, + }), + '/v1/users/me/ping', + 'post', + ); } - public async *getFeatureStatsByBrowserAndChannel( + public getFeatureStatsByBrowserAndChannel( featureId: string, browser: BrowsersParameter, channel: ChannelsParameter, @@ -474,345 +597,214 @@ export class APIClient { endAtDate: Date, metricView: components['schemas']['WPTMetricView'], ): AsyncIterable { - const startAt: string = startAtDate.toISOString().substring(0, 10); - const endAt: string = endAtDate.toISOString().substring(0, 10); + const startAt = startAtDate.toISOString().substring(0, 10); + const endAt = endAtDate.toISOString().substring(0, 10); - let nextPageToken; - do { - const response = await this.client.GET( - '/v1/features/{feature_id}/stats/wpt/browsers/{browser}/channels/{channel}/{metric_view}', - { - ...temporaryFetchOptions, - params: { - query: {startAt, endAt, page_token: nextPageToken}, - path: { - feature_id: featureId, - browser, - channel, - metric_view: metricView, - }, + return this.getAsyncIterableOfData( + '/v1/features/{feature_id}/stats/wpt/browsers/{browser}/channels/{channel}/{metric_view}', + { + params: { + query: {startAt, endAt}, + path: { + feature_id: featureId, + browser, + channel, + metric_view: metricView, }, }, - ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - const page: WPTRunMetricsPage = response.data as WPTRunMetricsPage; - nextPageToken = page?.metadata?.next_page_token; - - yield page.data; // Yield the entire page - } while (nextPageToken !== undefined); + }, + ); } - public async *getChromeDailyUsageStats( + public getChromeDailyUsageStats( featureId: string, startAtDate: Date, endAtDate: Date, ): AsyncIterable { - const startAt: string = startAtDate.toISOString().substring(0, 10); - const endAt: string = endAtDate.toISOString().substring(0, 10); - let nextPageToken; - do { - const response = await this.client.GET( - '/v1/features/{feature_id}/stats/usage/chrome/daily_stats', - { - ...temporaryFetchOptions, - params: { - query: {startAt, endAt, page_token: nextPageToken}, - path: { - feature_id: featureId, - }, + const startAt = startAtDate.toISOString().substring(0, 10); + const endAt = endAtDate.toISOString().substring(0, 10); + return this.getAsyncIterableOfData( + '/v1/features/{feature_id}/stats/usage/chrome/daily_stats', + { + params: { + query: {startAt, endAt}, + path: { + feature_id: featureId, }, }, - ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - const page: ChromeDailyUsageStatsPage = - response.data as ChromeDailyUsageStatsPage; - nextPageToken = page?.metadata?.next_page_token; - yield page.data; - } while (nextPageToken !== undefined); + }, + ); } // Fetches feature counts for a browser in a date range // via "/v1/stats/features/browsers/{browser}/feature_counts" - public async *getFeatureCountsForBrowser( + public getFeatureCountsForBrowser( browser: BrowsersParameter, startAtDate: Date, endAtDate: Date, ): AsyncIterable { - const startAt: string = startAtDate.toISOString().substring(0, 10); - const endAt: string = endAtDate.toISOString().substring(0, 10); + const startAt = startAtDate.toISOString().substring(0, 10); + const endAt = endAtDate.toISOString().substring(0, 10); - let nextPageToken; - do { - const response = await this.client.GET( - '/v1/stats/features/browsers/{browser}/feature_counts', - { - ...temporaryFetchOptions, - params: { - query: { - startAt, - endAt, - page_token: nextPageToken, - include_baseline_mobile_browsers: true, - }, - path: {browser}, + return this.getAsyncIterableOfData( + '/v1/stats/features/browsers/{browser}/feature_counts', + { + params: { + query: { + startAt, + endAt, + include_baseline_mobile_browsers: true, }, + path: {browser}, }, - ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - const page: BrowserReleaseFeatureMetricsPage = - response.data as BrowserReleaseFeatureMetricsPage; - nextPageToken = page?.metadata?.next_page_token; - yield page.data; // Yield the entire page - } while (nextPageToken !== undefined); + }, + ); } // Returns the count of features supported that have reached baseline // via "/v1/stats/baseline_status/low_date_feature_counts" - public async *listAggregatedBaselineStatusCounts( + public listAggregatedBaselineStatusCounts( startAtDate: Date, endAtDate: Date, ): AsyncIterable { - const startAt: string = startAtDate.toISOString().substring(0, 10); - const endAt: string = endAtDate.toISOString().substring(0, 10); + const startAt = startAtDate.toISOString().substring(0, 10); + const endAt = endAtDate.toISOString().substring(0, 10); - let nextPageToken; - do { - const response = await this.client.GET( - '/v1/stats/baseline_status/low_date_feature_counts', - { - ...temporaryFetchOptions, - params: { - query: {startAt, endAt, page_token: nextPageToken}, - }, + return this.getAsyncIterableOfData( + '/v1/stats/baseline_status/low_date_feature_counts', + { + params: { + query: {startAt, endAt}, }, - ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - const page: BaselineStatusMetricsPage = - response.data as BaselineStatusMetricsPage; - nextPageToken = page?.metadata?.next_page_token; - yield page.data; // Yield the entire page - } while (nextPageToken !== undefined); + }, + ); } // Fetches feature counts for a browser in a date range // via "/v1/stats/features/browsers/{browser}/feature_counts" - public async *getMissingOneImplementationCountsForBrowser( + public getMissingOneImplementationCountsForBrowser( browser: BrowsersParameter, otherBrowsers: BrowsersParameter[], startAtDate: Date, endAtDate: Date, ): AsyncIterable { - const startAt: string = startAtDate.toISOString().substring(0, 10); - const endAt: string = endAtDate.toISOString().substring(0, 10); + const startAt = startAtDate.toISOString().substring(0, 10); + const endAt = endAtDate.toISOString().substring(0, 10); - let nextPageToken; - do { - const response = await this.client.GET( - '/v1/stats/features/browsers/{browser}/missing_one_implementation_counts', - { - ...temporaryFetchOptions, - params: { - query: { - startAt, - endAt, - page_token: nextPageToken, - browser: otherBrowsers, - include_baseline_mobile_browsers: true, - }, - path: {browser}, + return this.getAsyncIterableOfData( + '/v1/stats/features/browsers/{browser}/missing_one_implementation_counts', + { + params: { + query: { + startAt, + endAt, + browser: otherBrowsers, + include_baseline_mobile_browsers: true, }, + path: {browser}, }, - ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - const page: BrowserReleaseFeatureMetricsPage = - response.data as BrowserReleaseFeatureMetricsPage; - nextPageToken = page?.metadata?.next_page_token; - yield page.data; // Yield the entire page - } while (nextPageToken !== undefined); + }, + ); } // Fetches missing feature list for a browser for a give date // via "/v1/stats/features/browsers/{browser}/missing_one_implementation_counts/{date}/features" - public async getMissingOneImplementationFeatures( + public getMissingOneImplementationFeatures( targetBrowser: BrowsersParameter, otherBrowsers: BrowsersParameter[], date: Date, ): Promise { const targetDate: string = date.toISOString().substring(0, 10); - let nextPageToken: string | undefined; - const allFeatures: MissingOneImplFeaturesList = []; - - do { - const response = await this.client.GET( - '/v1/stats/features/browsers/{browser}/missing_one_implementation_counts/{date}/features', - { - ...temporaryFetchOptions, - params: { - query: { - page_token: nextPageToken, - browser: otherBrowsers, - include_baseline_mobile_browsers: true, - }, - path: {browser: targetBrowser, date: targetDate}, + return this.getAllPagesOfData( + '/v1/stats/features/browsers/{browser}/missing_one_implementation_counts/{date}/features', + { + params: { + query: { + browser: otherBrowsers, + include_baseline_mobile_browsers: true, }, + path: {browser: targetBrowser, date: targetDate}, }, - ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - const page: MissingOneImplFeaturesPage = - response.data as MissingOneImplFeaturesPage; - - if (page?.data) { - allFeatures.push(...page.data); - } - - nextPageToken = page?.metadata?.next_page_token; - } while (nextPageToken !== undefined); - - return allFeatures; + }, + ); } - public async getSavedSearchByID( + public getSavedSearchByID( searchID: string, token?: string, ): Promise { - const options = { - ...temporaryFetchOptions, - params: { - path: { - search_id: searchID, - }, - }, - }; - // If the token is there, add it to the options - if (token) { - options.headers = { - Authorization: `Bearer ${token}`, - }; - } - const response = await this.client.GET( + return this.handleResponse( + this.client.GET('/v1/saved-searches/{search_id}', { + params: {path: {search_id: searchID}}, + headers: token ? {Authorization: `Bearer ${token}`} : undefined, + }), '/v1/saved-searches/{search_id}', - options, + 'get', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - - return response.data; } - public async removeSavedSearchByID(searchID: string, token: string) { - const options = { - ...temporaryFetchOptions, - params: { - path: { - search_id: searchID, - }, - }, - headers: { - Authorization: `Bearer ${token}`, - }, - }; - const response = await this.client.DELETE( + public removeSavedSearchByID(searchID: string, token: string): Promise { + return this.handleResponse( + this.client.DELETE('/v1/saved-searches/{search_id}', { + params: {path: {search_id: searchID}}, + headers: {Authorization: `Bearer ${token}`}, + }), '/v1/saved-searches/{search_id}', - options, + 'delete', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - - return response.data; } - public async putUserSavedSearchBookmark(searchID: string, token: string) { - const options = { - ...temporaryFetchOptions, - params: { - path: { - search_id: searchID, + public putUserSavedSearchBookmark( + searchID: string, + token: string, + ): Promise { + return this.handleResponse( + this.client.PUT( + '/v1/users/me/saved-searches/{search_id}/bookmark_status', + { + params: {path: {search_id: searchID}}, + headers: {Authorization: `Bearer ${token}`}, }, - }, - headers: { - Authorization: `Bearer ${token}`, - }, - }; - const response = await this.client.PUT( + ), '/v1/users/me/saved-searches/{search_id}/bookmark_status', - options, + 'put', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - - return response.data; } - public async removeUserSavedSearchBookmark(searchID: string, token: string) { - const options = { - ...temporaryFetchOptions, - params: { - path: { - search_id: searchID, + public removeUserSavedSearchBookmark( + searchID: string, + token: string, + ): Promise { + return this.handleResponse( + this.client.DELETE( + '/v1/users/me/saved-searches/{search_id}/bookmark_status', + { + params: {path: {search_id: searchID}}, + headers: {Authorization: `Bearer ${token}`}, }, - }, - headers: { - Authorization: `Bearer ${token}`, - }, - }; - const response = await this.client.DELETE( + ), '/v1/users/me/saved-searches/{search_id}/bookmark_status', - options, + 'delete', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - - return response.data; } - public async createSavedSearch( + public createSavedSearch( token: string, savedSearch: components['schemas']['SavedSearch'], ): Promise { - const options: FetchOptions< - FilterKeys - > = { - headers: { - Authorization: `Bearer ${token}`, - }, - body: savedSearch, - credentials: temporaryFetchOptions.credentials, - }; - const response = await this.client.POST('/v1/saved-searches', options); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - return response.data; + return this.handleResponse( + this.client.POST('/v1/saved-searches', { + headers: { + Authorization: `Bearer ${token}`, + }, + body: savedSearch, + }), + '/v1/saved-searches', + 'post', + ); } - public async updateSavedSearch( + public updateSavedSearch( savedSearch: UpdateSavedSearchInput, token: string, ): Promise { @@ -831,127 +823,78 @@ export class APIClient { req.update_mask.push('query'); req.query = savedSearch.query; } - const options: FetchOptions< - FilterKeys - > = { - headers: { - Authorization: `Bearer ${token}`, - }, - params: { - path: { - search_id: savedSearch.id, + return this.handleResponse( + this.client.PATCH('/v1/saved-searches/{search_id}', { + headers: { + Authorization: `Bearer ${token}`, }, - }, - body: req, - credentials: temporaryFetchOptions.credentials, - }; - const response = await this.client.PATCH( + params: { + path: { + search_id: savedSearch.id, + }, + }, + body: req, + }), '/v1/saved-searches/{search_id}', - options, + 'patch', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - return response.data; } - public async getSubscription( + public getSubscription( subscriptionId: string, token: string, ): Promise { - const options = { - ...temporaryFetchOptions, - params: { - path: { - subscription_id: subscriptionId, - }, - }, - headers: { - Authorization: `Bearer ${token}`, - }, - }; - const response = await this.client.GET( + return this.handleResponse( + this.client.GET('/v1/users/me/subscriptions/{subscription_id}', { + params: {path: {subscription_id: subscriptionId}}, + headers: {Authorization: `Bearer ${token}`}, + }), '/v1/users/me/subscriptions/{subscription_id}', - options, + 'get', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - - return response.data; } - public async deleteSubscription(subscriptionId: string, token: string) { - const options = { - ...temporaryFetchOptions, - params: { - path: { - subscription_id: subscriptionId, - }, - }, - headers: { - Authorization: `Bearer ${token}`, - }, - }; - const response = await this.client.DELETE( + public deleteSubscription( + subscriptionId: string, + token: string, + ): Promise { + return this.handleResponse( + this.client.DELETE('/v1/users/me/subscriptions/{subscription_id}', { + params: {path: {subscription_id: subscriptionId}}, + headers: {Authorization: `Bearer ${token}`}, + }), '/v1/users/me/subscriptions/{subscription_id}', - options, + 'delete', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - - return response.data; } public async listSubscriptions( token: string, ): Promise { - type SubscriptionPage = SuccessResponsePageableData< - paths['/v1/users/me/subscriptions']['get'], - ParamsOption<'/v1/users/me/subscriptions'>, - 'application/json', - '/v1/users/me/subscriptions' - >; - - return this.getAllPagesOfData< - '/v1/users/me/subscriptions', - SubscriptionPage - >('/v1/users/me/subscriptions', { + return this.getAllPagesOfData('/v1/users/me/subscriptions', { headers: { Authorization: `Bearer ${token}`, }, }); } - public async createSubscription( + public createSubscription( token: string, subscription: components['schemas']['Subscription'], ): Promise { - const options: FetchOptions< - FilterKeys - > = { - headers: { - Authorization: `Bearer ${token}`, - }, - body: subscription, - credentials: temporaryFetchOptions.credentials, - }; - const response = await this.client.POST( + return this.handleResponse( + this.client.POST('/v1/users/me/subscriptions', { + headers: { + Authorization: `Bearer ${token}`, + }, + body: subscription, + }), '/v1/users/me/subscriptions', - options, + 'post', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - return response.data; } - public async updateSubscription( + public updateSubscription( subscriptionId: string, token: string, updates: { @@ -970,28 +913,20 @@ export class APIClient { req.update_mask.push('frequency'); req.frequency = updates.frequency; } - const options: FetchOptions< - FilterKeys - > = { - headers: { - Authorization: `Bearer ${token}`, - }, - params: { - path: { - subscription_id: subscriptionId, + return this.handleResponse( + this.client.PATCH('/v1/users/me/subscriptions/{subscription_id}', { + headers: { + Authorization: `Bearer ${token}`, }, - }, - body: req, - credentials: temporaryFetchOptions.credentials, - }; - const response = await this.client.PATCH( + params: { + path: { + subscription_id: subscriptionId, + }, + }, + body: req, + }), '/v1/users/me/subscriptions/{subscription_id}', - options, + 'patch', ); - const error = response.error; - if (error !== undefined) { - throw createAPIError(error); - } - return response.data; } } diff --git a/frontend/src/static/js/api/errors.ts b/frontend/src/static/js/api/errors.ts index 9f282b9b4..0fbb5098e 100644 --- a/frontend/src/static/js/api/errors.ts +++ b/frontend/src/static/js/api/errors.ts @@ -16,19 +16,22 @@ import {type components} from 'webstatus.dev-backend'; * limitations under the License. */ -export function createAPIError(error?: unknown): ApiError { +export function createAPIError( + error?: {} | null | undefined, + statusCode?: number, +): ApiError { let message = 'Unknown error'; - let code = 500; // Default to Internal Server Error + let code = statusCode || 500; // Use provided status code as default if ( error instanceof Object && 'message' in error && - typeof error.message === 'string' && - 'code' in error && - typeof error.code === 'number' + typeof error.message === 'string' ) { message = error.message; - code = error.code; + if ('code' in error && typeof error.code === 'number') { + code = error.code; + } } else if (error instanceof Error) { message = error.message; } diff --git a/frontend/src/static/js/components/test/webstatus-feature-page.test.ts b/frontend/src/static/js/components/test/webstatus-feature-page.test.ts index 7ded1049d..2504ff2bb 100644 --- a/frontend/src/static/js/components/test/webstatus-feature-page.test.ts +++ b/frontend/src/static/js/components/test/webstatus-feature-page.test.ts @@ -539,7 +539,11 @@ describe('webstatus-feature-page', () => { }; const fakeError = new FeatureMovedError('foo', 'new-feature', newFeature); - el.handleMovedFeature('old-feature', fakeError); + el.handleMovedFeature( + 'old-feature', + fakeError.newFeatureId, + fakeError.feature, + ); expect(el.featureId).to.equal('new-feature'); expect(el.oldFeatureId).to.equal('old-feature'); diff --git a/frontend/src/static/js/components/webstatus-columns-dialog.ts b/frontend/src/static/js/components/webstatus-columns-dialog.ts index 7eb454860..8d66b7f92 100644 --- a/frontend/src/static/js/components/webstatus-columns-dialog.ts +++ b/frontend/src/static/js/components/webstatus-columns-dialog.ts @@ -105,9 +105,7 @@ export class WebstatusColumnsDialog extends LitElement { getColumnOptions(this.location), ); const checkboxes: TemplateResult[] = []; - for (const enumKeyStr of Object.keys(ColumnKey)) { - const ck = enumKeyStr as keyof typeof ColumnKey; - const columnId = ColumnKey[ck]; + for (const columnId of Object.values(ColumnKey)) { const displayName = CELL_DEFS[columnId].nameInDialog; const cellColumnOptions = CELL_DEFS[columnId].options.columnOptions; const checkbox = html` @@ -115,7 +113,7 @@ export class WebstatusColumnsDialog extends LitElement { ${displayName} @@ -125,7 +123,7 @@ export class WebstatusColumnsDialog extends LitElement { ${option.nameInDialog} @@ -167,9 +165,7 @@ export async function openColumnsDialog(location: { search: string; }): Promise { if (!columnsDialogEl) { - columnsDialogEl = document.createElement( - 'webstatus-columns-dialog', - ) as WebstatusColumnsDialog; + columnsDialogEl = new WebstatusColumnsDialog(); document.body.appendChild(columnsDialogEl); await columnsDialogEl.updateComplete; } diff --git a/frontend/src/static/js/components/webstatus-feature-gone-split-page.ts b/frontend/src/static/js/components/webstatus-feature-gone-split-page.ts index 11cc0eaf4..9ccfb427e 100644 --- a/frontend/src/static/js/components/webstatus-feature-gone-split-page.ts +++ b/frontend/src/static/js/components/webstatus-feature-gone-split-page.ts @@ -20,9 +20,14 @@ import {customElement, property, state} from 'lit/decorators.js'; import {SHARED_STYLES} from '../css/shared-css.js'; import {GITHUB_REPO_ISSUE_LINK} from '../utils/constants.js'; import {consume} from '@lit/context'; -import {APIClient, apiClientContext} from '../contexts/api-client-context.js'; import {Task} from '@lit/task'; -import {FeatureWPTMetricViewType} from '../api/client.js'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import { + isWPTMetricViewType, + APIClient, + DEFAULT_TEST_VIEW, + FeatureWPTMetricViewType, +} from '../api/client.js'; import {formatFeaturePageUrl, getWPTMetricView} from '../utils/urls.js'; type NewFeature = {name: string; url: string}; @@ -49,9 +54,12 @@ export class WebstatusFeatureGoneSplitPage extends LitElement { task: async ([apiClient, newFeatures]) => { if (!newFeatures) return []; const featureIds = newFeatures.split(','); - const wptMetricView = getWPTMetricView( - this.location, - ) as FeatureWPTMetricViewType; + const viewInUrl = getWPTMetricView(this.location); + const wptMetricView: FeatureWPTMetricViewType = isWPTMetricViewType( + viewInUrl, + ) + ? viewInUrl + : DEFAULT_TEST_VIEW; const features = await Promise.all( featureIds.map(id => apiClient.getFeature(id, wptMetricView)), ); diff --git a/frontend/src/static/js/components/webstatus-feature-page.ts b/frontend/src/static/js/components/webstatus-feature-page.ts index c76e8a5d6..89c5b3465 100644 --- a/frontend/src/static/js/components/webstatus-feature-page.ts +++ b/frontend/src/static/js/components/webstatus-feature-page.ts @@ -23,6 +23,7 @@ import {SHARED_STYLES} from '../css/shared-css.js'; import {type components} from 'webstatus.dev-backend'; import { + isWPTMetricViewType, FeatureWPTMetricViewType, type APIClient, type WPTRunMetric, @@ -99,7 +100,7 @@ export class FeaturePage extends BaseChartsPage { static get styles(): CSSResultGroup { return [ - super.styles!, + super.styles, SHARED_STYLES, css` .crumbs { @@ -233,19 +234,26 @@ export class FeaturePage extends BaseChartsPage { this._loadingTask = new Task(this, { args: () => [this.apiClient, this.featureId], task: async ([apiClient, featureId]) => { - if (typeof apiClient !== 'object' || typeof featureId !== 'string') { + if (!apiClient || !featureId) { return Promise.reject('api client and/or featureId not set'); } + const viewInUrl = getWPTMetricView(this.location); + const wptMetricView: FeatureWPTMetricViewType = isWPTMetricViewType( + viewInUrl, + ) + ? viewInUrl + : DEFAULT_TEST_VIEW; try { - const wptMetricView = getWPTMetricView( - this.location, - ) as FeatureWPTMetricViewType; const feature = await apiClient.getFeature(featureId, wptMetricView); this.feature = feature; return feature; } catch (error) { if (error instanceof FeatureMovedError) { - this.handleMovedFeature(featureId, error); + this.handleMovedFeature( + featureId, + error.newFeatureId, + error.feature, + ); // The task can now complete successfully with the new feature data. return error.feature; } @@ -278,7 +286,7 @@ export class FeaturePage extends BaseChartsPage { this._loadingMetadataTask = new Task(this, { args: () => [this.apiClient, this.featureId], task: async ([apiClient, featureId]) => { - if (typeof apiClient === 'object' && typeof featureId === 'string') { + if (apiClient && featureId) { this.featureMetadata = await apiClient.getFeatureMetadata(featureId); } return this.featureMetadata; @@ -286,10 +294,11 @@ export class FeaturePage extends BaseChartsPage { }); } - handleMovedFeature(oldFeatureId: string, error: FeatureMovedError) { - const newFeature = error.feature; - const newFeatureId = error.newFeatureId; - + handleMovedFeature( + oldFeatureId: string, + newFeatureId: string, + newFeature: components['schemas']['Feature'], + ) { // Set component state to render the new feature. this.feature = newFeature; this.featureId = newFeatureId; @@ -347,7 +356,7 @@ export class FeaturePage extends BaseChartsPage { renderCrumbs(): TemplateResult { const overviewUrl = formatOverviewPageUrl(this.location); const canonicalFeatureUrl = this.feature - ? formatFeaturePageUrl(this.feature!) + ? formatFeaturePageUrl(this.feature) : this.location; return html`
diff --git a/frontend/src/static/js/components/webstatus-feature-usage-chart-panel.ts b/frontend/src/static/js/components/webstatus-feature-usage-chart-panel.ts index ac0380dde..5a44a6ce1 100644 --- a/frontend/src/static/js/components/webstatus-feature-usage-chart-panel.ts +++ b/frontend/src/static/js/components/webstatus-feature-usage-chart-panel.ts @@ -88,12 +88,11 @@ export class WebstatusFeatureUsageChartPanel extends WebstatusLineChartPanel - [this.dataFetchStartDate, this.dataFetchEndDate, this.featureId] as [ - Date, - Date, - string, - ], + args: (): [Date, Date, string] => [ + this.dataFetchStartDate, + this.dataFetchEndDate, + this.featureId, + ], task: async ([startDate, endDate, featureId]: [Date, Date, string]) => { if ( featureId === undefined || @@ -126,17 +125,12 @@ export class WebstatusFeatureUsageChartPanel extends WebstatusLineChartPanel( - series: BrowsersParameter[], - ): { + getDisplayDataChartOptionsInput(series: BrowsersParameter[]): { seriesColors: string[]; vAxisTitle: string; } { // Compute seriesColors from selected browsers and BROWSER_ID_TO_COLOR - const seriesColors = series.map(browser => { - const browserKey = browser as keyof typeof BROWSER_ID_TO_COLOR; - return BROWSER_ID_TO_COLOR[browserKey]; - }); + const seriesColors = series.map(browser => BROWSER_ID_TO_COLOR[browser]); return { seriesColors: seriesColors, diff --git a/frontend/src/static/js/components/webstatus-feature-wpt-progress-chart-panel.ts b/frontend/src/static/js/components/webstatus-feature-wpt-progress-chart-panel.ts index 1c6d98a07..f16237e7e 100644 --- a/frontend/src/static/js/components/webstatus-feature-wpt-progress-chart-panel.ts +++ b/frontend/src/static/js/components/webstatus-feature-wpt-progress-chart-panel.ts @@ -103,13 +103,12 @@ export class WebstatusFeatureWPTProgressChartPanel extends WebstatusLineChartTab createLoadingTask(): Task { return new Task(this, { - args: () => - [ - this.dataFetchStartDate, - this.dataFetchEndDate, - this.featureId, - this.testView, - ] as [Date, Date, string, FeatureWPTMetricViewType], + args: (): [Date, Date, string, FeatureWPTMetricViewType] => [ + this.dataFetchStartDate, + this.dataFetchEndDate, + this.featureId, + this.testView, + ], task: async ([startDate, endDate, featureId, testView]: [ Date, Date, @@ -188,17 +187,13 @@ export class WebstatusFeatureWPTProgressChartPanel extends WebstatusLineChartTab } override readonly hasMax: boolean = true; - getDisplayDataChartOptionsInput( - browsers: BrowsersParameter[], - ): { + getDisplayDataChartOptionsInput(browsers: BrowsersParameter[]): { seriesColors: string[]; vAxisTitle: string; } { // Compute seriesColors from selected browsers and BROWSER_ID_TO_COLOR - const seriesColors = [...browsers, 'total'].map(browser => { - const browserKey = browser as keyof typeof BROWSER_ID_TO_COLOR; - return BROWSER_ID_TO_COLOR[browserKey]; - }); + const keys: (BrowsersParameter | 'total')[] = [...browsers, 'total']; + const seriesColors = keys.map(browser => BROWSER_ID_TO_COLOR[browser]); return { seriesColors: seriesColors, diff --git a/frontend/src/static/js/components/webstatus-gchart.ts b/frontend/src/static/js/components/webstatus-gchart.ts index 03739321a..703390a0b 100644 --- a/frontend/src/static/js/components/webstatus-gchart.ts +++ b/frontend/src/static/js/components/webstatus-gchart.ts @@ -22,7 +22,6 @@ import { CSSResultGroup, LitElement, PropertyValues, - type TemplateResult, css, html, nothing, @@ -102,10 +101,7 @@ export class WebstatusGChart extends LitElement { currentSelection: google.visualization.ChartSelection[] | undefined; @property({state: true, type: Object}) - dataTable: - | google.visualization.DataTable - | google.visualization.DataView - | undefined; + dataTable: google.visualization.DataTable | undefined; @state() chartWrapper: google.visualization.ChartWrapper | undefined; @@ -157,9 +153,12 @@ export class WebstatusGChart extends LitElement { private _resizeObserver: ResizeObserver | undefined; draw() { - if (this.chartWrapper) { - this.chartWrapper.draw(); - this.chartWrapper?.getChart()?.setSelection(this.currentSelection); + if (this.chartWrapper && this.containerId) { + const container = this.shadowRoot?.getElementById(this.containerId); + if (container) { + this.chartWrapper.draw(container); + this.chartWrapper.getChart()?.setSelection(this.currentSelection); + } } } @@ -307,18 +306,6 @@ export class WebstatusGChart extends LitElement { if (!this.chartWrapper) { this.chartWrapper = new google.visualization.ChartWrapper(); - - const extendedChartWrapper = this.chartWrapper as unknown as { - getContainer: () => Element; - }; - - // Since ChartWrapper wants to look up the container element by id, - // but it would fail to find it in the shadowDom, we have to replace the - // chartWrapper.getContainer method with a function that returns the div - // corresponding to this.containerId, which we know how to find. - extendedChartWrapper.getContainer = () => { - return this.shadowRoot!.getElementById(this.containerId!)!; - }; } } else { // If the library is not loaded, store the updated dataObj @@ -328,7 +315,7 @@ export class WebstatusGChart extends LitElement { } } - render(): TemplateResult { + override render() { const chartContainerClasses = classMap({ chart_container: true, loading: this.isRendering, @@ -369,9 +356,9 @@ export class WebstatusGChart extends LitElement { this.chartWrapper.setContainerId(this.containerId); // Still required? this.chartWrapper.setChartType(this.chartType); this.chartWrapper.setOptions(this.augmentOptions(this.options)); - this.chartWrapper.setDataTable( - this.dataTable as google.visualization.DataTable, - ); + if (this.dataTable) { + this.chartWrapper.setDataTable(this.dataTable); + } if (!this._chartClickListenerAdded) { // Check the flag google.visualization.events.addListener( diff --git a/frontend/src/static/js/components/webstatus-line-chart-panel.ts b/frontend/src/static/js/components/webstatus-line-chart-panel.ts index 4b1e6e5e3..b08c79f02 100644 --- a/frontend/src/static/js/components/webstatus-line-chart-panel.ts +++ b/frontend/src/static/js/components/webstatus-line-chart-panel.ts @@ -235,7 +235,7 @@ export abstract class WebstatusLineChartPanel extends LitElement { * @abstract * @returns {{seriesColors: Array; vAxisTitle: string;}} Chart options input. */ - abstract getDisplayDataChartOptionsInput(series: S[]): { + abstract getDisplayDataChartOptionsInput(series: S[]): { seriesColors: Array; vAxisTitle: string; }; @@ -398,7 +398,7 @@ export abstract class WebstatusLineChartPanel extends LitElement { * Renders an error message when an error occurs during data loading. * @returns {TemplateResult} The error message template. */ - renderChartWhenError(error: unknown): TemplateResult { + renderChartWhenError(error: {} | null | undefined): TemplateResult { return html`
extends LitElement { * @param {unknown} error The error encountered while loading details. * @returns {TemplateResult} The rendered content for the failure state. */ - _renderPointSelectFailure(error: unknown): TemplateResult { + _renderPointSelectFailure(error: {} | null | undefined): TemplateResult { return html`
extends WebstatusLineChartPanel { /** * The processed data objects for each view of the chart, structured for `webstatus-gchart`. @@ -51,7 +54,7 @@ export abstract class WebstatusLineChartTabbedPanel< * @abstract * @type {ArrayArray<>} */ - abstract browsersByView: Array>; + abstract browsersByView: Array>; _handleTabClick() { this.resetPointSelectedTask(); diff --git a/frontend/src/static/js/components/webstatus-notfound-error-page.ts b/frontend/src/static/js/components/webstatus-notfound-error-page.ts index 6a65e8d78..fb767e58b 100644 --- a/frontend/src/static/js/components/webstatus-notfound-error-page.ts +++ b/frontend/src/static/js/components/webstatus-notfound-error-page.ts @@ -22,7 +22,6 @@ import {getSearchQuery, formatFeaturePageUrl} from '../utils/urls.js'; import {consume} from '@lit/context'; import {APIClient, apiClientContext} from '../contexts/api-client-context.js'; import {Task} from '@lit/task'; -import {FeatureSortOrderType} from '../api/client.js'; import {Toast} from '../utils/toast.js'; type SimilarFeature = {name: string; url: string}; @@ -49,7 +48,7 @@ export class WebstatusNotFoundErrorPage extends LitElement { try { const response = await apiClient.getFeatures( featureId, - '' as FeatureSortOrderType, + undefined, undefined, 0, 5, diff --git a/frontend/src/static/js/components/webstatus-overview-cells.ts b/frontend/src/static/js/components/webstatus-overview-cells.ts index 23ba2018f..a6ab68626 100644 --- a/frontend/src/static/js/components/webstatus-overview-cells.ts +++ b/frontend/src/static/js/components/webstatus-overview-cells.ts @@ -94,13 +94,10 @@ export enum ColumnKey { DeveloperSignalUpvotes = 'developer_signal_upvotes', } -const columnKeyMapping = Object.entries(ColumnKey).reduce( - (mapping, [enumKey, enumValue]) => { - mapping[enumValue] = ColumnKey[enumKey as keyof typeof ColumnKey]; - return mapping; - }, - {} as Record, -); +const columnKeyMapping: Record = {}; +for (const val of Object.values(ColumnKey)) { + columnKeyMapping[val] = val; +} type ColumnOptionDefinition = { nameInDialog: string; @@ -112,14 +109,10 @@ export enum ColumnOptionKey { BaselineStatusLowDate = 'baseline_status_low_date', } -const columnOptionKeyMapping = Object.entries(ColumnOptionKey).reduce( - (mapping, [enumKey, enumValue]) => { - mapping[enumValue] = - ColumnOptionKey[enumKey as keyof typeof ColumnOptionKey]; - return mapping; - }, - {} as Record, -); +const columnOptionKeyMapping: Record = {}; +for (const val of Object.values(ColumnOptionKey)) { + columnOptionKeyMapping[val] = val; +} export const DEFAULT_COLUMNS = [ ColumnKey.Name, @@ -1033,7 +1026,9 @@ function renderInsufficentTestCoverage(): TemplateResult { `; } -export function didFeatureCrash(metadata?: {[key: string]: unknown}): boolean { +export function didFeatureCrash(metadata?: { + [key: string]: {} | null | undefined; +}): boolean { return !!metadata && 'status' in metadata && metadata['status'] === 'C'; } diff --git a/frontend/src/static/js/components/webstatus-overview-content.ts b/frontend/src/static/js/components/webstatus-overview-content.ts index 8d336e3e7..6bb7396ec 100644 --- a/frontend/src/static/js/components/webstatus-overview-content.ts +++ b/frontend/src/static/js/components/webstatus-overview-content.ts @@ -25,7 +25,6 @@ import { } from 'lit'; import {TaskStatus} from '@lit/task'; import {customElement, property, query, state} from 'lit/decorators.js'; -import {type components} from 'webstatus.dev-backend'; import './webstatus-overview-data-loader.js'; import './webstatus-overview-filters.js'; @@ -44,7 +43,11 @@ import { UserContext, firebaseUserContext, } from '../contexts/firebase-user-context.js'; -import {APIClient, apiClientContext} from '../contexts/api-client-context.js'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import { + type APIClient, + type SuccessResponsePageableData, +} from '../api/client.js'; import {WebstatusSavedSearchEditor} from './webstatus-saved-search-editor.js'; import { formatOverviewPageUrl, @@ -61,7 +64,10 @@ import { @customElement('webstatus-overview-content') export class WebstatusOverviewContent extends LitElement { @property({type: Object}) - taskTracker: TaskTracker = { + taskTracker: TaskTracker< + SuccessResponsePageableData<'/v1/features'>, + ApiError + > = { status: TaskStatus.INITIAL, // Initial state error: undefined, data: undefined, diff --git a/frontend/src/static/js/components/webstatus-overview-data-loader.ts b/frontend/src/static/js/components/webstatus-overview-data-loader.ts index 429a3a042..379b74471 100644 --- a/frontend/src/static/js/components/webstatus-overview-data-loader.ts +++ b/frontend/src/static/js/components/webstatus-overview-data-loader.ts @@ -34,11 +34,15 @@ import { CurrentSavedSearch, SavedSearchScope, } from '../contexts/app-bookmark-info-context.js'; +import {type SuccessResponsePageableData} from '../api/client.js'; @customElement('webstatus-overview-data-loader') export class WebstatusOverviewDataLoader extends LitElement { @property({type: Object}) - taskTracker: TaskTracker = { + taskTracker: TaskTracker< + SuccessResponsePageableData<'/v1/features'>, + ApiError + > = { status: TaskStatus.INITIAL, // Initial state error: undefined, data: undefined, @@ -109,20 +113,19 @@ export class WebstatusOverviewDataLoader extends LitElement { const columns: ColumnKey[] = parseColumnsSpec( getColumnsSpec(this.location), ); - const sortSpec: string = - getSortSpec(this.location) || (DEFAULT_SORT_SPEC as string); - const groupCells = renderGroupCells(this.location, columns, sortSpec); + const location = this.location; + if (!location) return html``; + const sortSpec = getSortSpec(location) || DEFAULT_SORT_SPEC; + const groupCells = renderGroupCells(location, columns, sortSpec!); let headerCells: TemplateResult[] = []; + const search = this.savedSearch; if ( - this.savedSearch?.scope === SavedSearchScope.GlobalSavedSearch && - this.savedSearch.value?.is_ordered + search?.scope === SavedSearchScope.GlobalSavedSearch && + search.value.is_ordered ) { - headerCells = renderSavedSearchHeaderCells( - this.savedSearch.value.name, - columns, - ); + headerCells = renderSavedSearchHeaderCells(search.value.name, columns); } else { - headerCells = renderHeaderCells(this.location, columns, sortSpec); + headerCells = renderHeaderCells(location, columns, sortSpec!); } if ( diff --git a/frontend/src/static/js/components/webstatus-overview-filters.ts b/frontend/src/static/js/components/webstatus-overview-filters.ts index 850489341..996b3c3ef 100644 --- a/frontend/src/static/js/components/webstatus-overview-filters.ts +++ b/frontend/src/static/js/components/webstatus-overview-filters.ts @@ -41,8 +41,8 @@ import {TaskStatus} from '@lit/task'; import { type APIClient, - type FeatureSortOrderType, - FeatureWPTMetricViewType, + isFeatureSortOrderType, + isWPTMetricViewType, BROWSER_ID_TO_LABEL, CHANNEL_ID_TO_LABEL, } from '../api/client.js'; @@ -161,13 +161,14 @@ export class WebstatusOverviewFilters extends LitElement { } handleDocumentKeyUp = (e: KeyboardEvent) => { - const inInputContext = e - .composedPath() - .some(el => - ['INPUT', 'TEXTAREA', 'SL-POPUP', 'SL-DIALOG'].includes( - (el as HTMLElement).tagName, - ), - ); + const inInputContext = e.composedPath().some(el => { + if (el instanceof HTMLElement) { + return ['INPUT', 'TEXTAREA', 'SL-POPUP', 'SL-DIALOG'].includes( + el.tagName, + ); + } + return false; + }); if (e.key === '/' && !inInputContext) { e.preventDefault(); e.stopPropagation(); @@ -188,10 +189,12 @@ export class WebstatusOverviewFilters extends LitElement { // Perform any initializations once the apiClient is passed to us via context. // TODO. allFeaturesFetcher should be moved to a separate task. this.allFeaturesFetcher = () => { + const sort = getSortSpec(this.location); + const wptMetricView = getWPTMetricView(this.location); return this.apiClient!.getAllFeatures( savedSearchHelpers.getCurrentQuery(this.appBookmarkInfo), - getSortSpec(this.location) as FeatureSortOrderType, - getWPTMetricView(this.location) as FeatureWPTMetricViewType, + isFeatureSortOrderType(sort) ? sort : undefined, + isWPTMetricViewType(wptMetricView) ? wptMetricView : undefined, ); }; } diff --git a/frontend/src/static/js/components/webstatus-overview-page.ts b/frontend/src/static/js/components/webstatus-overview-page.ts index ab19be8f7..85a076149 100644 --- a/frontend/src/static/js/components/webstatus-overview-page.ts +++ b/frontend/src/static/js/components/webstatus-overview-page.ts @@ -18,7 +18,6 @@ import {consume} from '@lit/context'; import {Task, TaskStatus} from '@lit/task'; import {LitElement, type TemplateResult, html, PropertyValueMap} from 'lit'; import {customElement, state, property} from 'lit/decorators.js'; -import {type components} from 'webstatus.dev-backend'; import { getPageSize, @@ -28,8 +27,9 @@ import { } from '../utils/urls.js'; import { type APIClient, - type FeatureSortOrderType, - FeatureWPTMetricViewType, + isFeatureSortOrderType, + isWPTMetricViewType, + type SuccessResponsePageableData, } from '../api/client.js'; import {apiClientContext} from '../contexts/api-client-context.js'; import './webstatus-overview-content.js'; @@ -53,7 +53,10 @@ export class OverviewPage extends LitElement { apiClient?: APIClient; @state() - taskTracker: TaskTracker = { + taskTracker: TaskTracker< + SuccessResponsePageableData<'/v1/features'>, + ApiError + > = { status: TaskStatus.INITIAL, // Initial state error: undefined, data: undefined, @@ -79,7 +82,7 @@ export class OverviewPage extends LitElement { args: () => [this.apiClient, this.location, this.appBookmarkInfo] as const, task: async ([apiClient, routerLocation, appBookmarkInfo]): Promise< - components['schemas']['FeaturePage'] + SuccessResponsePageableData<'/v1/features'> > => { this.taskTracker = { status: TaskStatus.INITIAL, @@ -96,7 +99,7 @@ export class OverviewPage extends LitElement { data: page, }; }, - onError: async (error: unknown) => { + onError: async (error: {} | null | undefined) => { if (error instanceof ApiError) { this.taskTracker = { status: TaskStatus.ERROR, @@ -159,10 +162,10 @@ export class OverviewPage extends LitElement { apiClient: APIClient | undefined, routerLocation: {search: string}, appBookmarkInfo?: AppBookmarkInfo, - ): Promise { - if (typeof apiClient !== 'object') + ): Promise> { + if (!apiClient) return Promise.reject(new Error('APIClient is not initialized.')); - const sortSpec = getSortSpec(routerLocation) as FeatureSortOrderType; + const sort = getSortSpec(routerLocation); let searchQuery: string = ''; const query = savedSearchHelpers.getCurrentQuery(appBookmarkInfo); if (query) { @@ -170,13 +173,11 @@ export class OverviewPage extends LitElement { } const offset = getPaginationStart(routerLocation); const pageSize = getPageSize(routerLocation); - const wptMetricView = getWPTMetricView( - routerLocation, - ) as FeatureWPTMetricViewType; + const wptMetricView = getWPTMetricView(routerLocation); return apiClient.getFeatures( searchQuery, - sortSpec, - wptMetricView, + isFeatureSortOrderType(sort) ? sort : undefined, + isWPTMetricViewType(wptMetricView) ? wptMetricView : undefined, offset, pageSize, ); diff --git a/frontend/src/static/js/components/webstatus-overview-pagination.ts b/frontend/src/static/js/components/webstatus-overview-pagination.ts index c143cc633..3b75f58d7 100644 --- a/frontend/src/static/js/components/webstatus-overview-pagination.ts +++ b/frontend/src/static/js/components/webstatus-overview-pagination.ts @@ -34,6 +34,7 @@ import { } from '../utils/urls.js'; import {navigateToUrl} from '../utils/app-router.js'; import {SHARED_STYLES} from '../css/shared-css.js'; +import {SlSelect} from '@shoelace-style/shoelace'; @customElement('webstatus-overview-pagination') export class WebstatusOverviewPagination extends LitElement { @@ -168,10 +169,16 @@ export class WebstatusOverviewPagination extends LitElement { } setItemsPerPage(event: Event): void { - const target = event.target as HTMLInputElement; - const newSize = parseInt(target.value); - const url = formatOverviewPageUrl(this.location, {num: newSize}); - navigateToUrl(url); + const target = event.target; + if (target instanceof SlSelect) { + const value = target.value; + if (typeof value !== 'string') { + return; + } + const newSize = parseInt(value); + const url = formatOverviewPageUrl(this.location, {num: newSize}); + navigateToUrl(url); + } } renderItemsPerPage(): TemplateResult { diff --git a/frontend/src/static/js/components/webstatus-page.ts b/frontend/src/static/js/components/webstatus-page.ts index 8a94e7218..c0015fc8f 100644 --- a/frontend/src/static/js/components/webstatus-page.ts +++ b/frontend/src/static/js/components/webstatus-page.ts @@ -18,7 +18,6 @@ import {LitElement, type TemplateResult, html, CSSResultGroup, css} from 'lit'; import {customElement} from 'lit/decorators.js'; import {SHARED_STYLES} from '../css/shared-css.js'; import {DRAWER_WIDTH_PX, IS_MOBILE} from './utils.js'; -import SlDrawer from '@shoelace-style/shoelace/dist/components/drawer/drawer.js'; import './webstatus-sidebar.js'; @customElement('webstatus-page') @@ -68,15 +67,17 @@ export class WebstatusPage extends LitElement { } firstUpdated(): void { - const sidebarDrawer = this.shadowRoot?.querySelector( - '#sidebar-drawer', - ) as SlDrawer | null; - if (!sidebarDrawer) { + const sidebarDrawer = this.shadowRoot?.querySelector('#sidebar-drawer'); + if (!(sidebarDrawer instanceof HTMLElement)) { throw new Error('Sidebar Drawer is missing'); } const showSidebarDrawer = () => { - void sidebarDrawer!.show(); + if ('show' in sidebarDrawer && typeof sidebarDrawer.show === 'function') { + void sidebarDrawer.show(); + } else { + sidebarDrawer.setAttribute('open', ''); + } }; if (!IS_MOBILE) { @@ -84,8 +85,15 @@ export class WebstatusPage extends LitElement { } document.addEventListener('toggle-menu', () => { - if (sidebarDrawer.open === true) { - void sidebarDrawer.hide(); + if (sidebarDrawer.hasAttribute('open')) { + if ( + 'hide' in sidebarDrawer && + typeof sidebarDrawer.hide === 'function' + ) { + void sidebarDrawer.hide(); + } else { + sidebarDrawer.removeAttribute('open'); + } } else { showSidebarDrawer(); } diff --git a/frontend/src/static/js/components/webstatus-saved-search-controls.ts b/frontend/src/static/js/components/webstatus-saved-search-controls.ts index 253d3884c..91287a282 100644 --- a/frontend/src/static/js/components/webstatus-saved-search-controls.ts +++ b/frontend/src/static/js/components/webstatus-saved-search-controls.ts @@ -141,7 +141,7 @@ export class WebstatusSavedSearchControls extends LitElement { ); } }, - async onError(error: unknown) { + async onError(error: {} | null | undefined) { let message: string; if (error instanceof ApiError) { message = error.message; diff --git a/frontend/src/static/js/components/webstatus-saved-search-editor.ts b/frontend/src/static/js/components/webstatus-saved-search-editor.ts index b7cd743cb..afec394d3 100644 --- a/frontend/src/static/js/components/webstatus-saved-search-editor.ts +++ b/frontend/src/static/js/components/webstatus-saved-search-editor.ts @@ -155,8 +155,8 @@ export class WebstatusSavedSearchEditor extends LitElement { this._currentTask = new Task(this, { autoRun: false, task: async ([name, description, query, userContext, apiClient]) => { - const token = await userContext!.user.getIdToken(); - return apiClient!.createSavedSearch(token, { + const token = await userContext.user.getIdToken(); + return apiClient.createSavedSearch(token, { name: name, description: description !== '' ? description : undefined, query: query, @@ -179,7 +179,7 @@ export class WebstatusSavedSearchEditor extends LitElement { ); await this.close(); }, - onError: async (error: unknown) => { + onError: async (error: {} | null | undefined) => { let message: string; if (error instanceof ApiError) { message = error.message; @@ -246,7 +246,7 @@ export class WebstatusSavedSearchEditor extends LitElement { : undefined, query: query !== savedSearch.query ? query : undefined, }; - return apiClient!.updateSavedSearch(update, token); + return apiClient.updateSavedSearch(update, token); }, args: () => [ this.savedSearch!, @@ -266,7 +266,7 @@ export class WebstatusSavedSearchEditor extends LitElement { ); await this.close(); }, - onError: async (error: unknown) => { + onError: async (error: {} | null | undefined) => { let message: string; if (error instanceof ApiError) { message = error.message; @@ -288,8 +288,8 @@ export class WebstatusSavedSearchEditor extends LitElement { this._currentTask = new Task(this, { autoRun: false, task: async ([savedSearchID, userContext, apiClient]) => { - const token = await userContext!.user.getIdToken(); - await apiClient!.removeSavedSearchByID(savedSearchID!, token); + const token = await userContext.user.getIdToken(); + await apiClient.removeSavedSearchByID(savedSearchID!, token); return savedSearchID!; }, args: () => [this.savedSearch?.id, this.userContext, this.apiClient], @@ -303,7 +303,7 @@ export class WebstatusSavedSearchEditor extends LitElement { ); await this.close(); }, - onError: async (error: unknown) => { + onError: async (error: {} | null | undefined) => { let message: string; if (error instanceof ApiError) { message = error.message; diff --git a/frontend/src/static/js/components/webstatus-sidebar-menu.ts b/frontend/src/static/js/components/webstatus-sidebar-menu.ts index cb9a8796a..0c3846ac0 100644 --- a/frontend/src/static/js/components/webstatus-sidebar-menu.ts +++ b/frontend/src/static/js/components/webstatus-sidebar-menu.ts @@ -63,6 +63,15 @@ enum NavigationItemKey { NOTIFICATION_CHANNELS = 'notification-channels-item', } +const NAVIGATION_ITEM_KEYS = new Set(Object.values(NavigationItemKey)); +/** + * Type guard to safely validate if a value is a valid NavigationItemKey. + * This ensures that menu selection logic remains type-safe without unsafe casts. + */ +function isNavigationItemKey(value: string): value is NavigationItemKey { + return NAVIGATION_ITEM_KEYS.has(value); +} + interface NavigationItem { id: string; path: string; @@ -195,7 +204,8 @@ export class WebstatusSidebarMenu extends LitElement { } getNavTree(): SlTree | undefined { - return this.shadowRoot!.querySelector('sl-tree') as SlTree; + const tree = this.shadowRoot?.querySelector('sl-tree'); + return tree instanceof SlTree ? tree : undefined; } private highlightNavigationItem(tree: SlTree | undefined) { @@ -210,10 +220,8 @@ export class WebstatusSidebarMenu extends LitElement { ); if (matchingNavItem) { - const itemToSelect = tree.querySelector( - `#${matchingNavItem.id}`, - ) as SlTreeItem; - if (itemToSelect) { + const itemToSelect = tree.querySelector(`#${matchingNavItem.id}`); + if (itemToSelect instanceof SlTreeItem) { itemToSelect.selected = true; } } @@ -233,14 +241,15 @@ export class WebstatusSidebarMenu extends LitElement { this.highlightNavigationItem(tree); - tree!.addEventListener('sl-selection-change', () => { + tree.addEventListener('sl-selection-change', () => { const selectedItems = tree.selectedItems; if (selectedItems.length <= 0) { return; } - const selectedItem = selectedItems[0]; - const navigationItem = - navigationMap[selectedItem.id as NavigationItemKey]; + const selectedItemId = selectedItems[0].id; + const navigationItem = isNavigationItemKey(selectedItemId) + ? navigationMap[selectedItemId] + : undefined; if (!navigationItem) { return; } diff --git a/frontend/src/static/js/components/webstatus-stats-global-feature-count-chart-panel.ts b/frontend/src/static/js/components/webstatus-stats-global-feature-count-chart-panel.ts index 09dd241ef..efcafeddb 100644 --- a/frontend/src/static/js/components/webstatus-stats-global-feature-count-chart-panel.ts +++ b/frontend/src/static/js/components/webstatus-stats-global-feature-count-chart-panel.ts @@ -35,18 +35,13 @@ export class WebstatusStatsGlobalFeatureCountChartPanel extends WebstatusLineCha // https://github.com/mdn/browser-compat-data/blob/92d6876b420b0e6e69eb61256ed04827c9889063/browsers/edge.json#L53-L66 // Set offset to -500 days. override dataFetchStartDateOffsetMsec: number = -500 * 24 * 60 * 60 * 1000; - getDisplayDataChartOptionsInput( - browsers: BrowsersParameter[], - ): { + getDisplayDataChartOptionsInput(browsers: BrowsersParameter[]): { seriesColors: string[]; vAxisTitle: string; } { // Compute seriesColors from selected browsers and BROWSER_ID_TO_COLOR - const selectedBrowsers = browsers; - const seriesColors = [...selectedBrowsers, 'total'].map(browser => { - const browserKey = browser as keyof typeof BROWSER_ID_TO_COLOR; - return BROWSER_ID_TO_COLOR[browserKey]; - }); + const seriesColors = browsers.map(browser => BROWSER_ID_TO_COLOR[browser]); + seriesColors.push(BROWSER_ID_TO_COLOR['total']); return { seriesColors: seriesColors, @@ -81,8 +76,10 @@ export class WebstatusStatsGlobalFeatureCountChartPanel extends WebstatusLineCha createLoadingTask(): Task { return new Task(this, { - args: () => - [this.dataFetchStartDate, this.dataFetchEndDate] as [Date, Date], + args: (): [Date, Date] => [ + this.dataFetchStartDate, + this.dataFetchEndDate, + ], task: async ([startDate, endDate]: [Date, Date]) => { await this._populateDataForChart([ ...this._createFetchFunctionConfigs(startDate, endDate), diff --git a/frontend/src/static/js/components/webstatus-stats-missing-one-impl-chart-panel.ts b/frontend/src/static/js/components/webstatus-stats-missing-one-impl-chart-panel.ts index 15340f192..8c0a67022 100644 --- a/frontend/src/static/js/components/webstatus-stats-missing-one-impl-chart-panel.ts +++ b/frontend/src/static/js/components/webstatus-stats-missing-one-impl-chart-panel.ts @@ -124,8 +124,10 @@ export class WebstatusStatsMissingOneImplChartPanel extends WebstatusLineChartPa createLoadingTask(): Task { return new Task(this, { - args: () => - [this.dataFetchStartDate, this.dataFetchEndDate] as [Date, Date], + args: (): [Date, Date] => [ + this.dataFetchStartDate, + this.dataFetchEndDate, + ], task: async ([startDate, endDate]: [Date, Date]) => { const fetchFunctionConfigs = this._createFetchFunctionConfigs( startDate, @@ -136,18 +138,12 @@ export class WebstatusStatsMissingOneImplChartPanel extends WebstatusLineChartPa }); } - getDisplayDataChartOptionsInput( - browsers: BrowsersParameter[], - ): { + getDisplayDataChartOptionsInput(browsers: BrowsersParameter[]): { seriesColors: string[]; vAxisTitle: string; } { // Compute seriesColors from selected browsers and BROWSER_ID_TO_COLOR - const selectedBrowsers = browsers; - const seriesColors = [...selectedBrowsers].map(browser => { - const browserKey = browser as keyof typeof BROWSER_ID_TO_COLOR; - return BROWSER_ID_TO_COLOR[browserKey]; - }); + const seriesColors = browsers.map(browser => BROWSER_ID_TO_COLOR[browser]); return { seriesColors: seriesColors, @@ -217,7 +213,7 @@ export class WebstatusStatsMissingOneImplChartPanel extends WebstatusLineChartPa data: features, }; }, - onError: async (error: unknown) => { + onError: async (error: {} | null | undefined) => { if (error instanceof ApiError) { this.taskTracker = { status: TaskStatus.ERROR, diff --git a/frontend/src/static/js/components/webstatus-typeahead.ts b/frontend/src/static/js/components/webstatus-typeahead.ts index 41217ad24..0e7fe2067 100644 --- a/frontend/src/static/js/components/webstatus-typeahead.ts +++ b/frontend/src/static/js/components/webstatus-typeahead.ts @@ -151,7 +151,8 @@ export class WebstatusTypeahead extends LitElement { findPrefix() { const inputEl = this.slInputRef.value!.input; - const wholeStr = inputEl!.value; + if (!inputEl) return; + const wholeStr = inputEl.value; const caret = inputEl.selectionStart; if (caret === null || caret !== inputEl.selectionEnd) { // User has a range selected. @@ -184,7 +185,7 @@ export class WebstatusTypeahead extends LitElement { } async handleCandidateSelected(e: {detail: {item: SlMenuItem}}) { - const candidateValue = e.detail!.item!.value; + const candidateValue = e.detail.item.value; const inputEl = this.slInputRef.value!.input; const wholeStr = inputEl.value; // Don't add a space after the completed value: let the user type it. @@ -398,7 +399,7 @@ export class WebstatusTypeaheadDropdown extends SlDropdown { } @customElement('webstatus-typeahead-item') -export class WebstatusTypeaheadItem extends LitElement { +export class WebstatusTypeaheadItem extends SlMenuItem { @property() value: string; @@ -463,11 +464,9 @@ export class WebstatusTypeaheadItem extends LitElement { ]; } - handleMouseOver(event: Event) { - if (this.parentElement) { - (this.parentElement as SlMenu).setCurrentItem( - this as unknown as SlMenuItem, - ); + handleItemMouseOver(event: Event) { + if (this.parentElement instanceof SlMenu) { + this.parentElement.setCurrentItem(this); } event.stopPropagation(); } @@ -481,13 +480,13 @@ export class WebstatusTypeaheadItem extends LitElement { return html`${before}${matching}${after}`; } - render(): TemplateResult { + override render(): TemplateResult<1> { const highlightedValue = this.highlight(this.value); const highlightedDoc = this.highlight(this.doc); return html`