diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 65213e8e0..3b44e6d7d 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -29,10 +29,56 @@ jobs: run: yarn run format-check working-directory: ./js + typecheck: + name: Typecheck + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Set up Node + uses: actions/setup-node@v2 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "yarn" + cache-dependency-path: js/yarn.lock + + - name: Install JS dependencies + run: yarn --frozen-lockfile + working-directory: ./js + + - name: Typecheck + run: yarn run check-typings || true # REMOVE THIS ONCE TYPE SAFETY REACHED + working-directory: ./js + + type-coverage: + name: Type Coverage + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Set up Node + uses: actions/setup-node@v2 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "yarn" + cache-dependency-path: js/yarn.lock + + - name: Install JS dependencies + run: yarn --frozen-lockfile + working-directory: ./js + + - name: Check type coverage + run: yarn run check-typings-coverage + working-directory: ./js + build-prod: name: Build and commit runs-on: ubuntu-latest - needs: [prettier] + needs: [prettier, typecheck, type-coverage] # Only commit JS on push to master branch # Remember to change in `build-test` job too @@ -62,7 +108,7 @@ jobs: build-test: name: Test build runs-on: ubuntu-latest - needs: [prettier] + needs: [prettier, typecheck, type-coverage] # Inverse check of `build-prod` # Remember to change in `build-prod` job too diff --git a/.gitignore b/.gitignore index 2cad88a64..30e01fbd2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ tests/.phpunit.result.cache .vagrant .idea/* .vscode +js/coverage-ts diff --git a/js/package.json b/js/package.json index 2f3eb8108..0fa66d1f9 100644 --- a/js/package.json +++ b/js/package.json @@ -30,6 +30,7 @@ "flarum-webpack-config": "^2.0.0", "prettier": "^2.4.1", "typescript": "^4.4.4", + "typescript-coverage-report": "^0.6.1", "webpack": "^5.61.0", "webpack-cli": "^4.9.1", "webpack-merge": "^5.8.0" @@ -41,7 +42,9 @@ "format": "prettier --write src", "format-check": "prettier --check src", "clean-typings": "npx rimraf dist-typings && mkdir dist-typings", - "build-typings": "npm run clean-typings && cp -r src/@types dist-typings/@types && tsc" + "build-typings": "npm run clean-typings && cp -r src/@types dist-typings/@types && tsc", + "check-typings": "tsc --noEmit --emitDeclarationOnly false", + "check-typings-coverage": "typescript-coverage-report" }, "packageManager": "yarn@3.1.0" } diff --git a/js/src/@types/global.d.ts b/js/src/@types/global.d.ts index bffadbcc7..ec54dab6c 100644 --- a/js/src/@types/global.d.ts +++ b/js/src/@types/global.d.ts @@ -98,3 +98,10 @@ interface JSX { attrs: Record; }; } + +interface Event { + /** + * Whether this event should trigger a Mithril redraw. + */ + redraw: boolean; +} diff --git a/js/src/admin/AdminApplication.ts b/js/src/admin/AdminApplication.ts index e7008f283..a1c9ffc8c 100644 --- a/js/src/admin/AdminApplication.ts +++ b/js/src/admin/AdminApplication.ts @@ -6,6 +6,32 @@ import Navigation from '../common/components/Navigation'; import AdminNav from './components/AdminNav'; import ExtensionData from './utils/ExtensionData'; +export type Extension = { + id: string; + version: string; + description?: string; + icon?: { + name: string; + }; + links: { + authors?: { + name?: string; + link?: string; + }[]; + discuss?: string; + documentation?: string; + support?: string; + website?: string; + donate?: string; + source?: string; + }; + extra: { + 'flarum-extension': { + title: string; + }; + }; +}; + export default class AdminApplication extends Application { extensionData = new ExtensionData(); @@ -24,6 +50,20 @@ export default class AdminApplication extends Application { }, }; + /** + * Settings are serialized to the admin dashboard as strings. + * Additional encoding/decoding is possible, but must take + * place on the client side. + * + * @inheritdoc + */ + + data!: Application['data'] & { + extensions: Record; + settings: Record; + modelStatistics: Record; + }; + constructor() { super(); @@ -41,20 +81,20 @@ export default class AdminApplication extends Application { m.route.prefix = '#'; super.mount(); - m.mount(document.getElementById('app-navigation'), { + m.mount(document.getElementById('app-navigation')!, { view: () => Navigation.component({ className: 'App-backControl', drawer: true, }), }); - m.mount(document.getElementById('header-navigation'), Navigation); - m.mount(document.getElementById('header-primary'), HeaderPrimary); - m.mount(document.getElementById('header-secondary'), HeaderSecondary); - m.mount(document.getElementById('admin-navigation'), AdminNav); + m.mount(document.getElementById('header-navigation')!, Navigation); + m.mount(document.getElementById('header-primary')!, HeaderPrimary); + m.mount(document.getElementById('header-secondary')!, HeaderSecondary); + m.mount(document.getElementById('admin-navigation')!, AdminNav); } - getRequiredPermissions(permission) { + getRequiredPermissions(permission: string) { const required = []; if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) { diff --git a/js/src/admin/app.ts b/js/src/admin/app.ts index 007d866ea..ce4e2c121 100644 --- a/js/src/admin/app.ts +++ b/js/src/admin/app.ts @@ -2,7 +2,7 @@ import Admin from './AdminApplication'; const app = new Admin(); -// @ts-ignore +// @ts-expect-error We need to do this for backwards compatibility purposes. window.app = app; export default app; diff --git a/js/src/admin/components/ExtensionPage.js b/js/src/admin/components/ExtensionPage.tsx similarity index 78% rename from js/src/admin/components/ExtensionPage.js rename to js/src/admin/components/ExtensionPage.tsx index 73275ab9f..964e89176 100644 --- a/js/src/admin/components/ExtensionPage.js +++ b/js/src/admin/components/ExtensionPage.tsx @@ -12,26 +12,39 @@ import ExtensionPermissionGrid from './ExtensionPermissionGrid'; import isExtensionEnabled from '../utils/isExtensionEnabled'; import AdminPage from './AdminPage'; import ReadmeModal from './ReadmeModal'; +import RequestError from '../../common/utils/RequestError'; +import { Extension } from '../AdminApplication'; +import { IPageAttrs } from '../../common/components/Page'; +import type Mithril from 'mithril'; -export default class ExtensionPage extends AdminPage { - oninit(vnode) { +export interface ExtensionPageAttrs extends IPageAttrs { + id: string; +} + +export default class ExtensionPage extends AdminPage { + extension!: Extension; + + changingState = false; + + infoFields = { + discuss: 'fas fa-comment-alt', + documentation: 'fas fa-book', + support: 'fas fa-life-ring', + website: 'fas fa-link', + donate: 'fas fa-donate', + source: 'fas fa-code', + }; + + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); - this.extension = app.data.extensions[this.attrs.id]; - this.changingState = false; + const extension = app.data.extensions[this.attrs.id]; - this.infoFields = { - discuss: 'fas fa-comment-alt', - documentation: 'fas fa-book', - support: 'fas fa-life-ring', - website: 'fas fa-link', - donate: 'fas fa-donate', - source: 'fas fa-code', - }; - - if (!this.extension) { + if (!extension) { return m.route.set(app.route('dashboard')); } + + this.extension = extension; } className() { @@ -40,7 +53,7 @@ export default class ExtensionPage extends AdminPage { return this.extension.id + '-Page'; } - view() { + view(vnode: Mithril.VnodeDOM) { if (!this.extension) return null; return ( @@ -51,7 +64,7 @@ export default class ExtensionPage extends AdminPage {

{app.translator.trans('core.admin.extension.enable_to_see')}

) : ( -
{this.sections().toArray()}
+
{this.sections(vnode).toArray()}
)} ); @@ -92,10 +105,10 @@ export default class ExtensionPage extends AdminPage { ]; } - sections() { + sections(vnode: Mithril.VnodeDOM) { const items = new ItemList(); - items.add('content', this.content()); + items.add('content', this.content(vnode)); items.add('permissions', [
@@ -117,7 +130,7 @@ export default class ExtensionPage extends AdminPage { return items; } - content() { + content(vnode: Mithril.VnodeDOM) { const settings = app.extensionData.getSettings(this.extension.id); return ( @@ -126,7 +139,7 @@ export default class ExtensionPage extends AdminPage { {settings ? (
{settings.map(this.buildSettingComponent.bind(this))} -
{this.submitButton()}
+
{this.submitButton(vnode)}
) : (

{app.translator.trans('core.admin.extension.no_settings')}

@@ -172,21 +185,17 @@ export default class ExtensionPage extends AdminPage { const links = this.extension.links; - if (links.authors.length) { - let authors = []; - - links.authors.map((author) => { - authors.push( - - {author.name} - - ); - }); + if (links.authors?.length) { + const authors = links.authors.map((author) => ( + + {author.name} + + )); items.add('authors', [icon('fas fa-user'), {punctuateSeries(authors)}]); } - Object.keys(this.infoFields).map((field) => { + (Object.keys(this.infoFields) as (keyof ExtensionPage['infoFields'])[]).map((field) => { if (links[field]) { items.add( field, @@ -240,7 +249,7 @@ export default class ExtensionPage extends AdminPage { return isExtensionEnabled(this.extension.id); } - onerror(e) { + onerror(e: RequestError) { // We need to give the modal animation time to start; if we close the modal too early, // it breaks the bootstrap modal library. // TODO: This workaround should be removed when we move away from bootstrap JS for modals. @@ -254,14 +263,16 @@ export default class ExtensionPage extends AdminPage { throw e; } - const error = e.response.errors[0]; + const error = e.response?.errors?.[0]; - app.alerts.show( - { type: 'error' }, - app.translator.trans(`core.lib.error.${error.code}_message`, { - extension: error.extension, - extensions: error.extensions.join(', '), - }) - ); + if (error) { + app.alerts.show( + { type: 'error' }, + app.translator.trans(`core.lib.error.${error.code}_message`, { + extension: error.extension, + extensions: (error.extensions as string[]).join(', '), + }) + ); + } } } diff --git a/js/src/admin/components/LoadingModal.js b/js/src/admin/components/LoadingModal.tsx similarity index 56% rename from js/src/admin/components/LoadingModal.js rename to js/src/admin/components/LoadingModal.tsx index df2cb70b8..2a9555d88 100644 --- a/js/src/admin/components/LoadingModal.js +++ b/js/src/admin/components/LoadingModal.tsx @@ -1,11 +1,11 @@ import app from '../../admin/app'; import Modal from '../../common/components/Modal'; -export default class LoadingModal extends Modal { +export default class LoadingModal extends Modal { /** * @inheritdoc */ - static isDismissible = false; + static readonly isDismissible: boolean = false; className() { return 'LoadingModal Modal--small'; @@ -18,4 +18,8 @@ export default class LoadingModal extends Modal { content() { return ''; } + + onsubmit(e: Event): void { + throw new Error('LoadingModal should not throw errors.'); + } } diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts index 7aa4d0570..cb6b25124 100644 --- a/js/src/admin/index.ts +++ b/js/src/admin/index.ts @@ -8,6 +8,7 @@ export { app }; import compatObj from './compat'; import proxifyCompat from '../common/utils/proxifyCompat'; +// @ts-expect-error The `app` instance needs to be available on compat. compatObj.app = app; export const compat = proxifyCompat(compatObj, 'admin'); diff --git a/js/src/admin/resolvers/ExtensionPageResolver.ts b/js/src/admin/resolvers/ExtensionPageResolver.ts index 6ab785c73..899e97130 100644 --- a/js/src/admin/resolvers/ExtensionPageResolver.ts +++ b/js/src/admin/resolvers/ExtensionPageResolver.ts @@ -1,14 +1,18 @@ import app from '../../admin/app'; import DefaultResolver from '../../common/resolvers/DefaultResolver'; +import ExtensionPage, { ExtensionPageAttrs } from '../components/ExtensionPage'; /** * A custom route resolver for ExtensionPage that generates handles routes * to default extension pages or a page provided by an extension. */ -export default class ExtensionPageResolver extends DefaultResolver { +export default class ExtensionPageResolver< + Attrs extends ExtensionPageAttrs = ExtensionPageAttrs, + RouteArgs extends Record = {} +> extends DefaultResolver, RouteArgs> { static extension: string | null = null; - onmatch(args, requestedPath, route) { + onmatch(args: Attrs & RouteArgs, requestedPath: string, route: string) { const extensionPage = app.extensionData.getPage(args.id); if (extensionPage) { diff --git a/js/src/common/Application.tsx b/js/src/common/Application.tsx index 210f41a70..f840fdea8 100644 --- a/js/src/common/Application.tsx +++ b/js/src/common/Application.tsx @@ -11,9 +11,10 @@ import Session from './Session'; import extract from './utils/extract'; import Drawer from './utils/Drawer'; import mapRoutes from './utils/mapRoutes'; -import RequestError from './utils/RequestError'; +import RequestError, { InternalFlarumRequestOptions } from './utils/RequestError'; import ScrollListener from './utils/ScrollListener'; import liveHumanTimes from './utils/liveHumanTimes'; +// @ts-expect-error We need to explicitly use the prefix to distinguish between the extend folder. import { extend } from './extend.ts'; import Forum from './models/Forum'; @@ -40,7 +41,7 @@ export type FlarumGenericRoute = RouteItem< >; export interface FlarumRequestOptions extends Omit, 'extract'> { - errorHandler: (errorMessage: string) => void; + errorHandler?: (error: RequestError) => void; url: string; // TODO: [Flarum 2.0] Remove deprecated option /** @@ -48,13 +49,13 @@ export interface FlarumRequestOptions extends Omit string; + extract?: (responseText: string) => string; /** * Manipulate the response text before it is parsed into JSON. * * This overrides any `extract` method provided. */ - modifyText: (responseText: string) => string; + modifyText?: (responseText: string) => string; } /** @@ -248,12 +249,12 @@ export default class Application { initialRoute!: string; - load(payload: Application['data']) { + public load(payload: Application['data']) { this.data = payload; this.translator.setLocale(payload.locale); } - boot() { + public boot() { this.initializers.toArray().forEach((initializer) => initializer(this)); this.store.pushPayload({ data: this.data.resources }); @@ -268,7 +269,7 @@ export default class Application { } // TODO: This entire system needs a do-over for v2 - bootExtensions(extensions: Record) { + public bootExtensions(extensions: Record) { Object.keys(extensions).forEach((name) => { const extension = extensions[name]; @@ -278,12 +279,13 @@ export default class Application { const extenders = extension.extend.flat(Infinity); for (const extender of extenders) { + // @ts-expect-error This is beyond saving atm. extender.extend(this, { name, exports: extension }); } }); } - mount(basePath: string = '') { + protected mount(basePath: string = '') { // An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html m.mount(document.getElementById('modal')!, { view: () => ModalManager.component({ state: this.modal }) }); m.mount(document.getElementById('alerts')!, { view: () => AlertManager.component({ state: this.alerts }) }); @@ -367,22 +369,36 @@ export default class Application { document.title = count + pageTitleWithSeparator + title; } - /** - * Make an AJAX request, handling any low-level errors that may occur. - * - * @see https://mithril.js.org/request.html - * - * @param options - * @return {Promise} - */ - request(originalOptions: FlarumRequestOptions): Promise { - const options = { ...originalOptions }; + protected transformRequestOptions(flarumOptions: FlarumRequestOptions): InternalFlarumRequestOptions { + const { background, deserialize, errorHandler, extract, modifyText, ...tmpOptions } = { ...flarumOptions }; - // Set some default options if they haven't been overridden. We want to - // authenticate all requests with the session token. We also want all - // requests to run asynchronously in the background, so that they don't - // prevent redraws from occurring. - options.background ||= true; + // Unless specified otherwise, requests should run asynchronously in the + // background, so that they don't prevent redraws from occurring. + const defaultBackground = true; + + // When we deserialize JSON data, if for some reason the server has provided + // a dud response, we don't want the application to crash. We'll show an + // error message to the user instead. + + // @ts-expect-error Typescript doesn't know we return promisified `ReturnType` OR `string`, + // so it errors due to Mithril's typings + const defaultDeserialize = (response: string) => response as ResponseType; + + const defaultErrorHandler = (error: RequestError) => { + throw error; + }; + + // When extracting the data from the response, we can check the server + // response code and show an error message to the user if something's gone + // awry. + const originalExtract = modifyText || extract; + + const options: InternalFlarumRequestOptions = { + background: background ?? defaultBackground, + deserialize: deserialize ?? defaultDeserialize, + errorHandler: errorHandler ?? defaultErrorHandler, + ...tmpOptions, + }; extend(options, 'config', (_: undefined, xhr: XMLHttpRequest) => { xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken!); @@ -401,37 +417,19 @@ export default class Application { options.method = 'POST'; } - // When we deserialize JSON data, if for some reason the server has provided - // a dud response, we don't want the application to crash. We'll show an - // error message to the user instead. - - // @ts-expect-error Typescript doesn't know we return promisified `ReturnType` OR `string`, - // so it errors due to Mithril's typings - options.deserialize ||= (responseText: string) => responseText; - - options.errorHandler ||= (error) => { - throw error; - }; - - // When extracting the data from the response, we can check the server - // response code and show an error message to the user if something's gone - // awry. - const original = options.modifyText || options.extract; - - // @ts-expect-error options.extract = (xhr: XMLHttpRequest) => { let responseText; - if (original) { - responseText = original(xhr.responseText); + if (originalExtract) { + responseText = originalExtract(xhr.responseText); } else { - responseText = xhr.responseText || null; + responseText = xhr.responseText; } const status = xhr.status; if (status < 200 || status > 299) { - throw new RequestError(`${status}`, `${responseText}`, options, xhr); + throw new RequestError(status, `${responseText}`, options, xhr); } if (xhr.getResponseHeader) { @@ -440,25 +438,38 @@ export default class Application { } try { - // @ts-expect-error return JSON.parse(responseText); } catch (e) { - throw new RequestError('500', `${responseText}`, options, xhr); + throw new RequestError(500, `${responseText}`, options, xhr); } }; + return options; + } + + /** + * Make an AJAX request, handling any low-level errors that may occur. + * + * @see https://mithril.js.org/request.html + * + * @param options + * @return {Promise} + */ + request(originalOptions: FlarumRequestOptions): Promise { + const options = this.transformRequestOptions(originalOptions); + if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert); // Now make the request. If it's a failure, inspect the error that was // returned and show an alert containing its contents. return m.request(options).then( (response) => response, - (error) => { + (error: RequestError) => { let content; switch (error.status) { case 422: - content = (error.response.errors as Record[]) + content = ((error.response?.errors ?? {}) as Record[]) .map((error) => [error.detail,
]) .flat() .slice(0, -1); @@ -490,7 +501,7 @@ export default class Application { // contains a formatted errors if possible, response must be an JSON API array of errors // the details property is decoded to transform escaped characters such as '\n' const errors = error.response && error.response.errors; - const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail)); + const formattedError = (Array.isArray(errors) && errors?.[0]?.detail && errors.map((e) => decodeURI(e.detail ?? ''))) || undefined; error.alert = { type: 'error', @@ -505,18 +516,22 @@ export default class Application { try { options.errorHandler(error); } catch (error) { - if (isDebug && error.xhr) { - const { method, url } = error.options; - const { status = '' } = error.xhr; + if (error instanceof RequestError) { + if (isDebug && error.xhr) { + const { method, url } = error.options; + const { status = '' } = error.xhr; - console.group(`${method} ${url} ${status}`); + console.group(`${method} ${url} ${status}`); - console.error(...(formattedError || [error])); + console.error(...(formattedError || [error])); - console.groupEnd(); + console.groupEnd(); + } + + this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content); + } else { + throw error; } - - this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content); } return Promise.reject(error); diff --git a/js/src/common/Component.ts b/js/src/common/Component.ts index a22959593..ac2178909 100644 --- a/js/src/common/Component.ts +++ b/js/src/common/Component.ts @@ -121,7 +121,7 @@ export default abstract class Component(attrs: SAttrs = {} as SAttrs, children: Mithril.Children = null): Mithril.Vnode { const componentAttrs = { ...attrs }; return m(this as any, componentAttrs, children); diff --git a/js/src/common/Fragment.ts b/js/src/common/Fragment.ts index b196be17e..a3886db39 100644 --- a/js/src/common/Fragment.ts +++ b/js/src/common/Fragment.ts @@ -34,8 +34,8 @@ export default abstract class Fragment { * @returns {jQuery} the jQuery object for the DOM node * @final */ - public $(selector) { - const $element = $(this.element); + public $(selector?: string): JQuery { + const $element = $(this.element) as JQuery; return selector ? $element.find(selector) : $element; } diff --git a/js/src/common/Translator.tsx b/js/src/common/Translator.tsx index 8830ecd80..3f211bf63 100644 --- a/js/src/common/Translator.tsx +++ b/js/src/common/Translator.tsx @@ -1,6 +1,7 @@ import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter'; import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter'; import username from './helpers/username'; +import User from './models/User'; import extract from './utils/extract'; type Translations = Record; @@ -50,7 +51,7 @@ export default class Translator { // translation key is used. if ('user' in parameters) { - const user = extract(parameters, 'user'); + const user = extract(parameters, 'user') as User; if (!parameters.username) parameters.username = username(user); } diff --git a/js/src/common/components/Alert.tsx b/js/src/common/components/Alert.tsx index 7e344517d..e206b591c 100644 --- a/js/src/common/components/Alert.tsx +++ b/js/src/common/components/Alert.tsx @@ -20,21 +20,21 @@ export interface AlertAttrs extends ComponentAttrs { * some controls, and may be dismissible. */ export default class Alert extends Component { - view(vnode: Mithril.Vnode) { + view(vnode: Mithril.VnodeDOM) { const attrs = Object.assign({}, this.attrs); const type = extract(attrs, 'type'); attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || ''); const content = extract(attrs, 'content') || vnode.children; - const controls = (extract(attrs, 'controls') || []) as Mithril.ChildArray; + const controls = (extract(attrs, 'controls') || []) as Mithril.Vnode[]; // If the alert is meant to be dismissible (which is the case by default), // then we will create a dismiss button to append as the final control in // the alert. const dismissible = extract(attrs, 'dismissible'); const ondismiss = extract(attrs, 'ondismiss'); - const dismissControl = []; + const dismissControl: Mithril.Vnode[] = []; if (dismissible || dismissible === undefined) { dismissControl.push(; } - oncreate(vnode: Mithril.VnodeDOM) { + oncreate(vnode: Mithril.VnodeDOM) { super.oncreate(vnode); const { 'aria-label': ariaLabel } = this.attrs; diff --git a/js/src/common/components/Modal.tsx b/js/src/common/components/Modal.tsx index 8391b24cf..595cccdfe 100644 --- a/js/src/common/components/Modal.tsx +++ b/js/src/common/components/Modal.tsx @@ -22,7 +22,7 @@ export default abstract class Modal extends Component extends Component { - oninit(vnode) { + /** + * A class name to apply to the body while the route is active. + */ + protected bodyClass = ''; + + /** + * Whether we should scroll to the top of the page when its rendered. + */ + protected scrollTopOnCreate = true; + + /** + * Whether the browser should restore scroll state on refreshes. + */ + protected useBrowserScrollRestoration = true; + + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); app.previous = app.current; @@ -21,30 +37,9 @@ export default abstract class Page app.drawer.hide(); app.modal.close(); - - /** - * A class name to apply to the body while the route is active. - * - * @type {String} - */ - this.bodyClass = ''; - - /** - * Whether we should scroll to the top of the page when its rendered. - * - * @type {Boolean} - */ - this.scrollTopOnCreate = true; - - /** - * Whether the browser should restore scroll state on refreshes. - * - * @type {Boolean} - */ - this.useBrowserScrollRestoration = true; } - oncreate(vnode) { + oncreate(vnode: Mithril.VnodeDOM) { super.oncreate(vnode); if (this.bodyClass) { @@ -60,7 +55,7 @@ export default abstract class Page } } - onremove(vnode) { + onremove(vnode: Mithril.VnodeDOM) { super.onremove(vnode); if (this.bodyClass) { diff --git a/js/src/common/components/Tooltip.tsx b/js/src/common/components/Tooltip.tsx index e9a43e5c1..a1a9970fe 100644 --- a/js/src/common/components/Tooltip.tsx +++ b/js/src/common/components/Tooltip.tsx @@ -1,7 +1,6 @@ import Component from '../Component'; import type Mithril from 'mithril'; import classList from '../utils/classList'; -import { TooltipCreationOptions } from '../../../@types/tooltips'; import extractText from '../utils/extractText'; export interface TooltipAttrs extends Mithril.CommonAttributes { diff --git a/js/src/common/helpers/avatar.tsx b/js/src/common/helpers/avatar.tsx index 12fb3faac..4de36fc14 100644 --- a/js/src/common/helpers/avatar.tsx +++ b/js/src/common/helpers/avatar.tsx @@ -1,13 +1,16 @@ import type Mithril from 'mithril'; +import type { ComponentAttrs } from '../Component'; import User from '../models/User'; +export interface AvatarAttrs extends ComponentAttrs {} + /** * The `avatar` helper displays a user's avatar. * * @param user * @param attrs Attributes to apply to the avatar element */ -export default function avatar(user: User, attrs: Object = {}): Mithril.Vnode { +export default function avatar(user: User, attrs: ComponentAttrs = {}): Mithril.Vnode { attrs.className = 'Avatar ' + (attrs.className || ''); let content: string = ''; diff --git a/js/src/common/helpers/listItems.tsx b/js/src/common/helpers/listItems.tsx index 078f868bd..20a2b985b 100644 --- a/js/src/common/helpers/listItems.tsx +++ b/js/src/common/helpers/listItems.tsx @@ -1,15 +1,29 @@ import type Mithril from 'mithril'; +import Component, { ComponentAttrs } from '../Component'; import Separator from '../components/Separator'; import classList from '../utils/classList'; -import type * as Component from '../Component'; -function isSeparator(item: Mithril.Children): boolean { +export interface ModdedVnodeAttrs { + itemClassName?: string; + key?: string; +} + +export type ModdedVnode = Mithril.Vnode | {}> & { + itemName?: string; + itemClassName?: string; + tag: Mithril.Vnode['tag'] & { + isListItem?: boolean; + isActive?: (attrs: ComponentAttrs) => boolean; + }; +}; + +function isSeparator(item: ModdedVnode): boolean { return item.tag === Separator; } -function withoutUnnecessarySeparators(items: Mithril.Children): Mithril.Children { - const newItems: Mithril.Children = []; - let prevItem: Mithril.Child; +function withoutUnnecessarySeparators(items: ModdedVnode[]): ModdedVnode[] { + const newItems: ModdedVnode[] = []; + let prevItem: ModdedVnode; items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => { if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) { @@ -21,16 +35,6 @@ function withoutUnnecessarySeparators(items: Mithril.Children): Mithril.Children return newItems; } -export interface ModdedVnodeAttrs { - itemClassName?: string; - key?: string; -} - -export type ModdedVnode = Mithril.Vnode | {}> & { - itemName?: string; - itemClassName?: string; -}; - /** * The `listItems` helper wraps an array of components in the provided tag, * stripping out any unnecessary `Separator` components. @@ -39,12 +43,11 @@ export type ModdedVnode = Mithril.Vnode>( - items: ModdedVnode | ModdedVnode[], - customTag: string | Component.default = 'li', - attributes: Attrs = {} + rawItems: ModdedVnode | ModdedVnode[], + customTag: string | Component = 'li', + attributes: Attrs = {} as Attrs ): Mithril.Vnode[] { - if (!(items instanceof Array)) items = [items]; - + const items = rawItems instanceof Array ? rawItems : [rawItems]; const Tag = customTag; return withoutUnnecessarySeparators(items).map((item: ModdedVnode) => { diff --git a/js/src/common/helpers/userOnline.tsx b/js/src/common/helpers/userOnline.tsx index adc23fcd5..9a3572d18 100644 --- a/js/src/common/helpers/userOnline.tsx +++ b/js/src/common/helpers/userOnline.tsx @@ -5,8 +5,10 @@ import icon from './icon'; /** * The `useronline` helper displays a green circle if the user is online */ -export default function userOnline(user: User): Mithril.Vnode { +export default function userOnline(user: User): Mithril.Vnode<{}, {}> | null { if (user.lastSeenAt() && user.isOnline()) { return {icon('fas fa-circle')}; } + + return null; } diff --git a/js/src/common/states/ModalManagerState.ts b/js/src/common/states/ModalManagerState.ts index b01bc15e2..401d81b21 100644 --- a/js/src/common/states/ModalManagerState.ts +++ b/js/src/common/states/ModalManagerState.ts @@ -14,7 +14,7 @@ export default class ModalManagerState { attrs?: Record; } = null; - private closeTimeout?: number; + private closeTimeout?: NodeJS.Timeout; /** * Shows a modal dialog. @@ -36,7 +36,7 @@ export default class ModalManagerState { throw new Error(invalidModalWarning); } - clearTimeout(this.closeTimeout); + if (this.closeTimeout) clearTimeout(this.closeTimeout); this.modal = { componentClass, attrs }; diff --git a/js/src/common/states/PaginatedListState.ts b/js/src/common/states/PaginatedListState.ts index 77bba2ea6..5f13baf22 100644 --- a/js/src/common/states/PaginatedListState.ts +++ b/js/src/common/states/PaginatedListState.ts @@ -15,18 +15,22 @@ export interface PaginationLocation { endIndex?: number; } -export default abstract class PaginatedListState { +export interface PaginatedListParams { + [key: string]: any; +} + +export default abstract class PaginatedListState { protected location!: PaginationLocation; protected pageSize: number; protected pages: Page[] = []; - protected params: any = {}; + protected params: P = {} as P; protected initialLoading: boolean = false; protected loadingPrev: boolean = false; protected loadingNext: boolean = false; - protected constructor(params: any = {}, page: number = 1, pageSize: number = 20) { + protected constructor(params: P = {} as P, page: number = 1, pageSize: number = 20) { this.params = params; this.location = { page }; @@ -123,12 +127,14 @@ export default abstract class PaginatedListState { * @param page * @see requestParams */ - public refreshParams(newParams, page: number) { + public refreshParams(newParams: P, page: number): Promise { if (this.isEmpty() || this.paramsChanged(newParams)) { this.params = newParams; return this.refresh(page); } + + return Promise.resolve(); } public refresh(page: number = 1) { @@ -222,7 +228,7 @@ export default abstract class PaginatedListState { } } - protected paramsChanged(newParams): boolean { + protected paramsChanged(newParams: P): boolean { return Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key]); } diff --git a/js/src/common/utils/BasicEditorDriver.ts b/js/src/common/utils/BasicEditorDriver.ts index a57bf27c9..98d48683e 100644 --- a/js/src/common/utils/BasicEditorDriver.ts +++ b/js/src/common/utils/BasicEditorDriver.ts @@ -18,7 +18,7 @@ export default class BasicEditorDriver implements EditorDriverInterface { this.el.placeholder = params.placeholder; this.el.value = params.value; - const callInputListeners = (e) => { + const callInputListeners = (e: Event) => { params.inputListeners.forEach((listener) => { listener(); }); @@ -46,7 +46,7 @@ export default class BasicEditorDriver implements EditorDriverInterface { keyHandlers(params: EditorDriverParams): ItemList { const items = new ItemList(); - items.add('submit', function (e) { + items.add('submit', function (e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { params.onsubmit(); } diff --git a/js/src/common/utils/RequestError.ts b/js/src/common/utils/RequestError.ts index e9a62589c..7aa66e3ed 100644 --- a/js/src/common/utils/RequestError.ts +++ b/js/src/common/utils/RequestError.ts @@ -1,14 +1,28 @@ -export default class RequestError { +import type Mithril from 'mithril'; + +export type InternalFlarumRequestOptions = Mithril.RequestOptions & { + errorHandler: (error: RequestError) => void; + url: string; +}; + +export default class RequestError { status: number; - options: Record; + options: InternalFlarumRequestOptions; xhr: XMLHttpRequest; responseText: string | null; - response: Record | null; + response: { + [key: string]: unknown; + errors?: { + detail?: string; + code?: string; + [key: string]: unknown; + }[]; + } | null; alert: any; - constructor(status: number, responseText: string | null, options: Record, xhr: XMLHttpRequest) { + constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions, xhr: XMLHttpRequest) { this.status = status; this.responseText = responseText; this.options = options; diff --git a/js/src/common/utils/arrayFlatPolyfill.ts b/js/src/common/utils/arrayFlatPolyfill.ts index b1ca99011..96f95d705 100644 --- a/js/src/common/utils/arrayFlatPolyfill.ts +++ b/js/src/common/utils/arrayFlatPolyfill.ts @@ -3,12 +3,17 @@ // // Needed to provide support for Safari on iOS < 12 +// ts-ignored because we can afford to encapsulate some messy logic behind the clean typings. + if (!Array.prototype['flat']) { - Array.prototype['flat'] = function flat(this: any[], depth: number = 1): any[] { - return depth > 0 - ? Array.prototype.reduce.call(this, (acc, val): any[] => acc.concat(Array.isArray(val) ? flat.call(val, depth - 1) : val), []) + Array.prototype['flat'] = function flat(this: A, depth?: D | unknown): any[] { + // @ts-ignore + return (depth ?? 1) > 0 + ? // @ts-ignore + Array.prototype.reduce.call(this, (acc, val): any[] => acc.concat(Array.isArray(val) ? flat.call(val, depth - 1) : val), []) : // If no depth is provided, or depth is 0, just return a copy of // the array. Spread is supported in all major browsers (iOS 8+) + // @ts-ignore [...this]; }; } diff --git a/js/src/common/utils/humanTime.ts b/js/src/common/utils/humanTime.ts index 330323ea3..870153ae2 100644 --- a/js/src/common/utils/humanTime.ts +++ b/js/src/common/utils/humanTime.ts @@ -4,7 +4,7 @@ import dayjs from 'dayjs'; * The `humanTime` utility converts a date to a localized, human-readable time- * ago string. */ -export default function humanTime(time: Date): string { +export default function humanTime(time: dayjs.ConfigType): string { let d = dayjs(time); const now = dayjs(); diff --git a/js/src/common/utils/stringToColor.ts b/js/src/common/utils/stringToColor.ts index 4edd87cd7..e074438c2 100644 --- a/js/src/common/utils/stringToColor.ts +++ b/js/src/common/utils/stringToColor.ts @@ -1,9 +1,9 @@ type RGB = { r: number; g: number; b: number }; function hsvToRgb(h: number, s: number, v: number): RGB { - let r; - let g; - let b; + let r!: number; + let g!: number; + let b!: number; const i = Math.floor(h * 6); const f = h * 6 - i; diff --git a/js/src/common/utils/withAttr.ts b/js/src/common/utils/withAttr.ts index 682b54122..5055b74b9 100644 --- a/js/src/common/utils/withAttr.ts +++ b/js/src/common/utils/withAttr.ts @@ -11,5 +11,5 @@ */ export default (key: string, cb: Function) => function (this: Element) { - cb(this.getAttribute(key) || this[key]); + cb(this.getAttribute(key) || (this as any)[key]); }; diff --git a/js/src/forum/ForumApplication.ts b/js/src/forum/ForumApplication.ts index 4ee9385aa..6f20d8ff5 100644 --- a/js/src/forum/ForumApplication.ts +++ b/js/src/forum/ForumApplication.ts @@ -22,6 +22,7 @@ import isSafariMobile from './utils/isSafariMobile'; import type Notification from './components/Notification'; import type Post from './components/Post'; +import Discussion from '../common/models/Discussion'; export default class ForumApplication extends Application { /** @@ -134,11 +135,8 @@ export default class ForumApplication extends Application { /** * Check whether or not the user is currently viewing a discussion. - * - * @param {Discussion} discussion - * @return {Boolean} */ - viewingDiscussion(discussion) { + public viewingDiscussion(discussion: Discussion): boolean { return this.current.matches(DiscussionPage, { discussion }); } @@ -149,13 +147,8 @@ export default class ForumApplication extends Application { * If the payload indicates that the user has been logged in, then the page * will be reloaded. Otherwise, a SignUpModal will be opened, prefilled * with the provided details. - * - * @param {Object} payload A dictionary of attrs to pass into the sign up - * modal. A truthy `loggedIn` attr indicates that the user has logged - * in, and thus the page is reloaded. - * @public */ - authenticationComplete(payload) { + public authenticationComplete(payload: Record): void { if (payload.loggedIn) { window.location.reload(); } else { diff --git a/js/src/forum/app.ts b/js/src/forum/app.ts index 02daea014..83d12b935 100644 --- a/js/src/forum/app.ts +++ b/js/src/forum/app.ts @@ -2,7 +2,7 @@ import Forum from './ForumApplication'; const app = new Forum(); -// @ts-ignore +// @ts-expect-error We need to do this for backwards compatibility purposes. window.app = app; export default app; diff --git a/js/src/forum/components/DiscussionPage.js b/js/src/forum/components/DiscussionPage.tsx similarity index 85% rename from js/src/forum/components/DiscussionPage.js rename to js/src/forum/components/DiscussionPage.tsx index 6f4780b8e..231f5c936 100644 --- a/js/src/forum/components/DiscussionPage.js +++ b/js/src/forum/components/DiscussionPage.tsx @@ -1,5 +1,7 @@ +import type Mithril from 'mithril'; + import app from '../../forum/app'; -import Page from '../../common/components/Page'; +import Page, { IPageAttrs } from '../../common/components/Page'; import ItemList from '../../common/utils/ItemList'; import DiscussionHero from './DiscussionHero'; import DiscussionListPane from './DiscussionListPane'; @@ -10,31 +12,39 @@ import SplitDropdown from '../../common/components/SplitDropdown'; import listItems from '../../common/helpers/listItems'; import DiscussionControls from '../utils/DiscussionControls'; import PostStreamState from '../states/PostStreamState'; +import Discussion from '../../common/models/Discussion'; +import Post from '../../common/models/Post'; + +export interface IDiscussionPageAttrs extends IPageAttrs { + id: string; + near?: number; +} /** * The `DiscussionPage` component displays a whole discussion page, including * the discussion list pane, the hero, the posts, and the sidebar. */ -export default class DiscussionPage extends Page { - oninit(vnode) { +export default class DiscussionPage extends Page { + /** + * The discussion that is being viewed. + */ + protected discussion: Discussion | null = null; + + /** + * A public API for interacting with the post stream. + */ + protected stream: PostStreamState | null = null; + + /** + * The number of the first post that is currently visible in the viewport. + */ + protected near: number = 0; + + protected useBrowserScrollRestoration = true; + + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); - this.useBrowserScrollRestoration = false; - - /** - * The discussion that is being viewed. - * - * @type {Discussion} - */ - this.discussion = null; - - /** - * The number of the first post that is currently visible in the viewport. - * - * @type {number} - */ - this.near = m.route.param('near') || 0; - this.load(); // If the discussion list has been loaded, then we'll enable the pane (and @@ -43,25 +53,23 @@ export default class DiscussionPage extends Page { // then the pane would redraw which would be slow and would cause problems with // event handlers. if (app.discussions.hasItems()) { - app.pane.enable(); - app.pane.hide(); + app.pane?.enable(); + app.pane?.hide(); } - app.history.push('discussion'); - this.bodyClass = 'App--discussion'; } - onremove(vnode) { + onremove(vnode: Mithril.VnodeDOM) { super.onremove(vnode); // If we are indeed navigating away from this discussion, then disable the // discussion list pane. Also, if we're composing a reply to this // discussion, minimize the composer – unless it's empty, in which case // we'll just close it. - app.pane.disable(); + app.pane?.disable(); - if (app.composer.composingReplyTo(this.discussion) && !app.composer.fields.content()) { + if (app.composer.composingReplyTo(this.discussion) && !app.composer?.fields?.content()) { app.composer.hide(); } else { app.composer.minimize(); @@ -155,7 +163,7 @@ export default class DiscussionPage extends Page { * Load the discussion from the API or use the preloaded one. */ load() { - const preloadedDiscussion = app.preloadedApiDocument(); + const preloadedDiscussion = app.preloadedApiDocument() as Discussion | null; if (preloadedDiscussion) { // We must wrap this in a setTimeout because if we are mounting this // component for the first time on page load, then any calls to m.redraw @@ -186,10 +194,8 @@ export default class DiscussionPage extends Page { /** * Initialize the component to display the given discussion. - * - * @param {Discussion} discussion */ - show(discussion) { + show(discussion: Discussion) { app.history.push('discussion', discussion.title()); app.setTitle(discussion.title()); app.setTitleCount(0); @@ -214,7 +220,7 @@ export default class DiscussionPage extends Page { record.relationships.discussion.data.id === discussionId ) .map((record) => app.store.getById('posts', record.id)) - .sort((a, b) => a.createdAt() - b.createdAt()) + .sort((a: Post, b: Post) => a.createdAt() - b.createdAt()) .slice(0, 20); } @@ -266,13 +272,12 @@ export default class DiscussionPage extends Page { /** * When the posts that are visible in the post stream change (i.e. the user * scrolls up or down), then we update the URL and mark the posts as read. - * - * @param {Integer} startNumber - * @param {Integer} endNumber */ - positionChanged(startNumber, endNumber) { + positionChanged(startNumber: number, endNumber: number): void { const discussion = this.discussion; + if (!discussion) return; + // Construct a URL to this discussion with the updated position, then // replace it into the window's history and our own history stack. const url = app.route.discussion(discussion, (this.near = startNumber)); diff --git a/js/src/forum/components/DiscussionsSearchSource.tsx b/js/src/forum/components/DiscussionsSearchSource.tsx index 031569601..4cbd4dbfb 100644 --- a/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/js/src/forum/components/DiscussionsSearchSource.tsx @@ -4,15 +4,16 @@ import LinkButton from '../../common/components/LinkButton'; import Link from '../../common/components/Link'; import { SearchSource } from './Search'; import type Mithril from 'mithril'; +import Discussion from '../../common/models/Discussion'; /** * The `DiscussionsSearchSource` finds and displays discussion search results in * the search dropdown. */ export default class DiscussionsSearchSource implements SearchSource { - protected results = new Map(); + protected results = new Map(); - search(query: string) { + async search(query: string): Promise { query = query.toLowerCase(); this.results.set(query, []); @@ -23,13 +24,16 @@ export default class DiscussionsSearchSource implements SearchSource { include: 'mostRelevantPost', }; - return app.store.find('discussions', params).then((results) => this.results.set(query, results)); + return app.store.find('discussions', params).then((results) => { + this.results.set(query, results); + m.redraw(); + }); } view(query: string): Array { query = query.toLowerCase(); - const results = (this.results.get(query) || []).map((discussion: unknown) => { + const results = (this.results.get(query) || []).map((discussion) => { const mostRelevantPost = discussion.mostRelevantPost(); return ( diff --git a/js/src/forum/components/Search.tsx b/js/src/forum/components/Search.tsx index bba6dba0b..a38ff4424 100644 --- a/js/src/forum/components/Search.tsx +++ b/js/src/forum/components/Search.tsx @@ -10,6 +10,7 @@ import SearchState from '../states/SearchState'; import DiscussionsSearchSource from './DiscussionsSearchSource'; import UsersSearchSource from './UsersSearchSource'; import type Mithril from 'mithril'; +import Model from '../../common/Model'; /** * The `SearchSource` interface defines a section of search results in the @@ -24,8 +25,9 @@ import type Mithril from 'mithril'; export interface SearchSource { /** * Make a request to get results for the given query. + * The results will be updated internally in the search source, not exposed. */ - search(query: string); + search(query: string): Promise; /** * Get an array of virtual
  • s that list the search results for the given @@ -57,7 +59,7 @@ export default class Search extends Compone */ protected static MIN_SEARCH_LEN = 3; - protected state!: SearchState; + protected searchState!: SearchState; /** * Whether or not the search input has focus. @@ -84,18 +86,18 @@ export default class Search extends Compone protected navigator!: KeyboardNavigatable; - protected searchTimeout?: number; + protected searchTimeout?: NodeJS.Timeout; private updateMaxHeightHandler?: () => void; oninit(vnode: Mithril.Vnode) { super.oninit(vnode); - this.state = this.attrs.state; + this.searchState = this.attrs.state; } view() { - const currentSearch = this.state.getInitialSearch(); + const currentSearch = this.searchState.getInitialSearch(); // Initialize search sources in the view rather than the constructor so // that we have access to app.forum. @@ -107,15 +109,15 @@ export default class Search extends Compone const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder')); const isActive = !!currentSearch; - const shouldShowResults = !!(!this.loadingSources && this.state.getValue() && this.hasFocus); - const shouldShowClearButton = !!(!this.loadingSources && this.state.getValue()); + const shouldShowResults = !!(!this.loadingSources && this.searchState.getValue() && this.hasFocus); + const shouldShowClearButton = !!(!this.loadingSources && this.searchState.getValue()); return (
    extends Compone className="FormControl" type="search" placeholder={searchLabel} - value={this.state.getValue()} - oninput={(e) => this.state.setValue(e.target.value)} + value={this.searchState.getValue()} + oninput={(e: InputEvent) => this.searchState.setValue((e?.target as HTMLInputElement)?.value)} onfocus={() => (this.hasFocus = true)} onblur={() => (this.hasFocus = false)} /> @@ -148,7 +150,7 @@ export default class Search extends Compone aria-hidden={!shouldShowResults || undefined} aria-live={shouldShowResults ? 'polite' : undefined} > - {shouldShowResults && this.sources.map((source) => source.view(this.state.getValue()))} + {shouldShowResults && this.sources.map((source) => source.view(this.searchState.getValue()))}
    ); @@ -159,11 +161,12 @@ export default class Search extends Compone // we need to calculate and set the max height dynamically. const resultsElementMargin = 14; const maxHeight = - window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin; - this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`; + window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin; + + this.element.querySelector('.Search-results')?.setAttribute('style', `max-height: ${maxHeight}px`); } - onupdate(vnode) { + onupdate(vnode: Mithril.VnodeDOM) { super.onupdate(vnode); // Highlight the item that is currently selected. @@ -175,11 +178,11 @@ export default class Search extends Compone this.updateMaxHeight(); } - oncreate(vnode) { + oncreate(vnode: Mithril.VnodeDOM) { super.oncreate(vnode); const search = this; - const state = this.state; + const state = this.searchState; // Highlight the item that is currently selected. this.setIndex(this.getCurrentNumericIndex()); @@ -210,7 +213,7 @@ export default class Search extends Compone if (!query) return; - clearTimeout(search.searchTimeout); + if (search.searchTimeout) clearTimeout(search.searchTimeout); search.searchTimeout = setTimeout(() => { if (state.isCached(query)) return; @@ -242,21 +245,25 @@ export default class Search extends Compone window.addEventListener('resize', this.updateMaxHeightHandler); } - onremove(vnode) { + onremove(vnode: Mithril.VnodeDOM) { super.onremove(vnode); - window.removeEventListener('resize', this.updateMaxHeightHandler); + if (this.updateMaxHeightHandler) { + window.removeEventListener('resize', this.updateMaxHeightHandler); + } } /** * Navigate to the currently selected search result and close the list. */ selectResult() { - clearTimeout(this.searchTimeout); + if (this.searchTimeout) clearTimeout(this.searchTimeout); + this.loadingSources = 0; - if (this.state.getValue()) { - m.route.set(this.getItem(this.index).find('a').attr('href')); + const selectedUrl = this.getItem(this.index).find('a').attr('href'); + if (this.searchState.getValue() && selectedUrl) { + m.route.set(selectedUrl); } else { this.clear(); } @@ -268,7 +275,7 @@ export default class Search extends Compone * Clear the search */ clear() { - this.state.clear(); + this.searchState.clear(); } /** @@ -331,11 +338,11 @@ export default class Search extends Compone this.index = parseInt($item.attr('data-index') as string) || fixedIndex; if (scrollToItem) { - const dropdownScroll = $dropdown.scrollTop(); - const dropdownTop = $dropdown.offset().top; - const dropdownBottom = dropdownTop + $dropdown.outerHeight(); - const itemTop = $item.offset().top; - const itemBottom = itemTop + $item.outerHeight(); + const dropdownScroll = $dropdown.scrollTop()!; + const dropdownTop = $dropdown.offset()!.top; + const dropdownBottom = dropdownTop + $dropdown.outerHeight()!; + const itemTop = $item.offset()!.top; + const itemBottom = itemTop + $item.outerHeight()!; let scrollTop; if (itemTop < dropdownTop) { diff --git a/js/src/forum/components/UsersSearchSource.tsx b/js/src/forum/components/UsersSearchSource.tsx index e1160e65e..4d2776b60 100644 --- a/js/src/forum/components/UsersSearchSource.tsx +++ b/js/src/forum/components/UsersSearchSource.tsx @@ -1,19 +1,21 @@ +import type Mithril from 'mithril'; + import app from '../../forum/app'; import highlight from '../../common/helpers/highlight'; import avatar from '../../common/helpers/avatar'; import username from '../../common/helpers/username'; import Link from '../../common/components/Link'; import { SearchSource } from './Search'; -import type Mithril from 'mithril'; +import User from '../../common/models/User'; /** * The `UsersSearchSource` finds and displays user search results in the search * dropdown. */ export default class UsersSearchResults implements SearchSource { - protected results = new Map(); + protected results = new Map(); - search(query: string) { + async search(query: string): Promise { return app.store .find('users', { filter: { q: query }, diff --git a/js/src/forum/index.ts b/js/src/forum/index.ts index 0537213af..8f7605335 100644 --- a/js/src/forum/index.ts +++ b/js/src/forum/index.ts @@ -10,6 +10,7 @@ export { app }; import compatObj from './compat'; import proxifyCompat from '../common/utils/proxifyCompat'; +// @ts-ignore compatObj.app = app; export const compat = proxifyCompat(compatObj, 'forum'); diff --git a/js/src/forum/resolvers/DiscussionPageResolver.ts b/js/src/forum/resolvers/DiscussionPageResolver.ts index 49b5d872e..fb494f351 100644 --- a/js/src/forum/resolvers/DiscussionPageResolver.ts +++ b/js/src/forum/resolvers/DiscussionPageResolver.ts @@ -1,14 +1,19 @@ +import type Mithril from 'mithril'; + import app from '../../forum/app'; import DefaultResolver from '../../common/resolvers/DefaultResolver'; -import DiscussionPage from '../components/DiscussionPage'; +import DiscussionPage, { IDiscussionPageAttrs } from '../components/DiscussionPage'; /** * A custom route resolver for DiscussionPage that generates the same key to all posts * on the same discussion. It triggers a scroll when going from one post to another * in the same discussion. */ -export default class DiscussionPageResolver extends DefaultResolver { - static scrollToPostNumber: string | null = null; +export default class DiscussionPageResolver< + Attrs extends IDiscussionPageAttrs = IDiscussionPageAttrs, + RouteArgs extends Record = {} +> extends DefaultResolver, RouteArgs> { + static scrollToPostNumber: number | null = null; /** * Remove optional parts of a discussion's slug to keep the substring @@ -34,16 +39,16 @@ export default class DiscussionPageResolver extends DefaultResolver { return this.routeName.replace('.near', '') + JSON.stringify(params); } - onmatch(args, requestedPath, route) { + onmatch(args: Attrs & RouteArgs, requestedPath: string, route: string) { if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) { // By default, the first post number of any discussion is 1 - DiscussionPageResolver.scrollToPostNumber = args.near || '1'; + DiscussionPageResolver.scrollToPostNumber = args.near || 1; } return super.onmatch(args, requestedPath, route); } - render(vnode) { + render(vnode: Mithril.Vnode>) { if (DiscussionPageResolver.scrollToPostNumber !== null) { const number = DiscussionPageResolver.scrollToPostNumber; // Scroll after a timeout to avoid clashes with the render. diff --git a/js/src/forum/states/DiscussionListState.ts b/js/src/forum/states/DiscussionListState.ts index b4a967769..7e3715cd6 100644 --- a/js/src/forum/states/DiscussionListState.ts +++ b/js/src/forum/states/DiscussionListState.ts @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import PaginatedListState, { Page } from '../../common/states/PaginatedListState'; +import PaginatedListState, { Page, PaginatedListParams } from '../../common/states/PaginatedListState'; import Discussion from '../../common/models/Discussion'; export interface IRequestParams { @@ -8,10 +8,14 @@ export interface IRequestParams { sort?: string; } -export default class DiscussionListState extends PaginatedListState { +export interface DiscussionListParams extends PaginatedListParams { + sort?: string; +} + +export default class DiscussionListState

    extends PaginatedListState { protected extraDiscussions: Discussion[] = []; - constructor(params: any, page: number = 1) { + constructor(params: P, page: number = 1) { super(params, page, 20); } @@ -25,7 +29,7 @@ export default class DiscussionListState extends PaginatedListState filter: this.params.filter || {}, }; - params.sort = this.sortMap()[this.params.sort]; + params.sort = this.sortMap()[this.params.sort ?? '']; if (this.params.q) { params.filter.q = this.params.q; diff --git a/js/src/forum/utils/History.js b/js/src/forum/utils/History.ts similarity index 79% rename from js/src/forum/utils/History.js rename to js/src/forum/utils/History.ts index 4086238b9..faa8e8da4 100644 --- a/js/src/forum/utils/History.js +++ b/js/src/forum/utils/History.ts @@ -1,5 +1,11 @@ import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefresh'; +export interface HistoryEntry { + name: string; + title: string; + url: string; +} + /** * The `History` class keeps track and manages a stack of routes that the user * has navigated to in their session. @@ -12,46 +18,34 @@ import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefr * rather than the previous discussion. */ export default class History { - constructor(defaultRoute) { - /** - * The stack of routes that have been navigated to. - * - * @type {Array} - * @protected - */ - this.stack = []; - } + /** + * The stack of routes that have been navigated to. + */ + protected stack: HistoryEntry[] = []; /** * Get the item on the top of the stack. - * - * @return {Object} - * @public */ - getCurrent() { + getCurrent(): HistoryEntry { return this.stack[this.stack.length - 1]; } /** * Get the previous item on the stack. - * - * @return {Object} - * @public */ - getPrevious() { + getPrevious(): HistoryEntry { return this.stack[this.stack.length - 2]; } /** * Push an item to the top of the stack. * - * @param {String} name The name of the route. - * @param {String} title The title of the route. - * @param {String} [url] The URL of the route. The current URL will be used if + * @param {string} name The name of the route. + * @param {string} title The title of the route. + * @param {string} [url] The URL of the route. The current URL will be used if * not provided. - * @public */ - push(name, title, url = m.route.get()) { + push(name: string, title: string, url = m.route.get()) { // If we're pushing an item with the same name as second-to-top item in the // stack, we will assume that the user has clicked the 'back' button in // their browser. In this case, we don't want to push a new item, so we will @@ -74,18 +68,13 @@ export default class History { /** * Check whether or not the history stack is able to be popped. - * - * @return {Boolean} - * @public */ - canGoBack() { + canGoBack(): boolean { return this.stack.length > 1; } /** * Go back to the previous route in the history stack. - * - * @public */ back() { if (!this.canGoBack()) { @@ -99,10 +88,8 @@ export default class History { /** * Get the URL of the previous page. - * - * @public */ - backUrl() { + backUrl(): string { const secondTop = this.stack[this.stack.length - 2]; return secondTop.url; @@ -110,8 +97,6 @@ export default class History { /** * Go to the first route in the history stack. - * - * @public */ home() { this.stack.splice(0); diff --git a/js/src/forum/utils/KeyboardNavigatable.ts b/js/src/forum/utils/KeyboardNavigatable.ts index 739a590fc..53b1b2d59 100644 --- a/js/src/forum/utils/KeyboardNavigatable.ts +++ b/js/src/forum/utils/KeyboardNavigatable.ts @@ -91,7 +91,7 @@ export default class KeyboardNavigatable { */ onRemove(callback: KeyboardEventHandler): KeyboardNavigatable { this.callbacks.set(8, (e) => { - if (e.target.selectionStart === 0 && e.target.selectionEnd === 0) { + if (e instanceof KeyboardEvent && e.target instanceof HTMLInputElement && e.target.selectionStart === 0 && e.target.selectionEnd === 0) { callback(e); e.preventDefault(); } @@ -114,7 +114,7 @@ export default class KeyboardNavigatable { */ bindTo($element: JQuery) { // Handle navigation key events on the navigatable element. - $element.on('keydown', this.navigate.bind(this)); + $element[0].addEventListener('keydown', this.navigate.bind(this)); } /** diff --git a/js/src/forum/utils/isSafariMobile.ts b/js/src/forum/utils/isSafariMobile.ts index a842eb2c3..9cb355931 100644 --- a/js/src/forum/utils/isSafariMobile.ts +++ b/js/src/forum/utils/isSafariMobile.ts @@ -4,9 +4,9 @@ export default function isSafariMobile(): boolean { return ( 'ontouchstart' in window && - navigator.vendor && + navigator.vendor != null && navigator.vendor.includes('Apple') && - navigator.userAgent && + navigator.userAgent != null && !navigator.userAgent.includes('CriOS') && !navigator.userAgent.includes('FxiOS') ); diff --git a/js/yarn.lock b/js/yarn.lock index f0cfb1428..6ba8222f7 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -1321,6 +1321,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.1.2": + version: 7.16.3 + resolution: "@babel/runtime@npm:7.16.3" + dependencies: + regenerator-runtime: ^0.13.4 + checksum: ab8ac887096d76185ddbf291d28fb976cd32473696dc497ad4905b784acbd5aa462533ad83a5c5104e10ead28c2e0e119840ee28ed8eff90dcdde9d57f916eda + languageName: node + linkType: hard + "@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.14.0, @babel/runtime@npm:^7.8.4": version: 7.16.0 resolution: "@babel/runtime@npm:7.16.0" @@ -1404,6 +1413,7 @@ __metadata: textarea-caret: ^3.1.0 throttle-debounce: ^3.0.1 typescript: ^4.4.4 + typescript-coverage-report: ^0.6.1 webpack: ^5.61.0 webpack-cli: ^4.9.1 webpack-merge: ^5.8.0 @@ -1417,6 +1427,46 @@ __metadata: languageName: node linkType: hard +"@hypnosphi/create-react-context@npm:^0.3.1": + version: 0.3.1 + resolution: "@hypnosphi/create-react-context@npm:0.3.1" + dependencies: + gud: ^1.0.0 + warning: ^4.0.3 + peerDependencies: + prop-types: ^15.0.0 + react: ">=0.14.0" + checksum: d2f069a562e138057aa067e1483e28cea3193bbacd33ca9528131f31e656939cfeb552af760b3be437d3a8074315a8854fc6d5d89878e2746618ad930c817122 + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": 2.0.5 + run-parallel: ^1.1.9 + checksum: a970d595bd23c66c880e0ef1817791432dbb7acbb8d44b7e7d0e7a22f4521260d4a83f7f9fd61d44fda4610105577f8f58a60718105fb38352baed612fd79e59 + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 012480b5ca9d97bff9261571dbbec7bbc6033f69cc92908bc1ecfad0792361a5a1994bc48674b9ef76419d056a03efadfce5a6cf6dbc0a36559571a7a483f6f0 + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": 2.1.5 + fastq: ^1.6.0 + checksum: 190c643f156d8f8f277bf2a6078af1ffde1fd43f498f187c2db24d35b4b4b5785c02c7dc52e356497b9a1b65b13edc996de08de0b961c32844364da02986dc53 + languageName: node + linkType: hard + "@polka/url@npm:^1.0.0-next.20": version: 1.0.0-next.21 resolution: "@polka/url@npm:1.0.0-next.21" @@ -1424,6 +1474,46 @@ __metadata: languageName: node linkType: hard +"@semantic-ui-react/event-stack@npm:^3.1.0": + version: 3.1.2 + resolution: "@semantic-ui-react/event-stack@npm:3.1.2" + dependencies: + exenv: ^1.2.2 + prop-types: ^15.6.2 + peerDependencies: + react: ^16.0.0 || ^17.0.0 + react-dom: ^16.0.0 || ^17.0.0 + checksum: 3e3a27294e24478d57f324552008975a8c7b2deec9a974f627a75d7bc9fc1257da6c62907cf4684b7cb9b3b1252f5e9b2f312200d5dafcf1f019a645fd21ad0b + languageName: node + linkType: hard + +"@stardust-ui/react-component-event-listener@npm:~0.38.0": + version: 0.38.0 + resolution: "@stardust-ui/react-component-event-listener@npm:0.38.0" + dependencies: + "@babel/runtime": ^7.1.2 + prop-types: ^15.7.2 + peerDependencies: + react: ^16.8.0 + react-dom: ^16.8.0 + checksum: f9f54c976781f5bed766cded56be1a35b0d02bf1aa244db5db61e508420ae76241254772c626ee99322455793a38ffdffd195c3515e8a628a08457ba711ce1ac + languageName: node + linkType: hard + +"@stardust-ui/react-component-ref@npm:~0.38.0": + version: 0.38.0 + resolution: "@stardust-ui/react-component-ref@npm:0.38.0" + dependencies: + "@babel/runtime": ^7.1.2 + prop-types: ^15.7.2 + react-is: ^16.6.3 + peerDependencies: + react: ^16.8.0 + react-dom: ^16.8.0 + checksum: c5bd6ec22fdcfdaee37b255d68fef5420d84e47b080d3947bcd0d7063548e1a8aa1627285bdf8c6c85eb4516845c763d7c183e57505ca8eca7c3d87e266a83ec + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.0": version: 3.7.1 resolution: "@types/eslint-scope@npm:3.7.1" @@ -1903,6 +1993,15 @@ __metadata: languageName: node linkType: hard +"braces@npm:^3.0.1": + version: 3.0.2 + resolution: "braces@npm:3.0.2" + dependencies: + fill-range: ^7.0.1 + checksum: e2a8e769a863f3d4ee887b5fe21f63193a891c68b612ddb4b68d82d1b5f3ff9073af066c343e9867a393fe4c2555dcb33e89b937195feb9c1613d259edfcd459 + languageName: node + linkType: hard + "browserslist@npm:^4.14.5, browserslist@npm:^4.16.6, browserslist@npm:^4.17.6": version: 4.17.6 resolution: "browserslist@npm:4.17.6" @@ -1952,7 +2051,7 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.0": +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2": version: 1.0.2 resolution: "call-bind@npm:1.0.2" dependencies: @@ -2004,6 +2103,13 @@ __metadata: languageName: node linkType: hard +"classnames@npm:^2.2.6": + version: 2.3.1 + resolution: "classnames@npm:2.3.1" + checksum: 14db8889d56c267a591f08b0834989fe542d47fac659af5a539e110cc4266694e8de86e4e3bbd271157dbd831361310a8293e0167141e80b0f03a0f175c80960 + languageName: node + linkType: hard + "clone-deep@npm:^4.0.1": version: 4.0.1 resolution: "clone-deep@npm:4.0.1" @@ -2068,6 +2174,13 @@ __metadata: languageName: node linkType: hard +"colors@npm:^1.0.3, colors@npm:^1.4.0": + version: 1.4.0 + resolution: "colors@npm:1.4.0" + checksum: 98aa2c2418ad87dedf25d781be69dc5fc5908e279d9d30c34d8b702e586a0474605b3a189511482b9d5ed0d20c867515d22749537f7bc546256c6014f3ebdcec + languageName: node + linkType: hard + "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -2164,6 +2277,20 @@ __metadata: languageName: node linkType: hard +"deep-equal@npm:^1.1.1": + version: 1.1.1 + resolution: "deep-equal@npm:1.1.1" + dependencies: + is-arguments: ^1.0.4 + is-date-object: ^1.0.1 + is-regex: ^1.0.4 + object-is: ^1.0.1 + object-keys: ^1.1.1 + regexp.prototype.flags: ^1.2.0 + checksum: f92686f2c5bcdf714a75a5fa7a9e47cb374a8ec9307e717b8d1ce61f56a75aaebf5619c2a12b8087a705b5a2f60d0292c35f8b58cb1f72e3268a3a15cab9f78d + languageName: node + linkType: hard + "define-properties@npm:^1.1.3": version: 1.1.3 resolution: "define-properties@npm:1.1.3" @@ -2180,6 +2307,13 @@ __metadata: languageName: node linkType: hard +"eastasianwidth@npm:^0.1.0": + version: 0.1.1 + resolution: "eastasianwidth@npm:0.1.1" + checksum: d387cada4bbb8f50634098f0b204a9046cfb3748add45442232bae57b715692fbf1795154178293550cc28e08eee8ab1218cdcb28b7342c238dfd3bf42fbba10 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.3.886": version: 1.3.888 resolution: "electron-to-chromium@npm:1.3.888" @@ -2307,6 +2441,13 @@ __metadata: languageName: node linkType: hard +"exenv@npm:^1.2.2": + version: 1.2.2 + resolution: "exenv@npm:1.2.2" + checksum: a894f3b60ab8419e0b6eec99c690a009c8276b4c90655ccaf7d28faba2de3a6b93b3d92210f9dc5efd36058d44f04098f6bbccef99859151104bfd49939904dc + languageName: node + linkType: hard + "expose-loader@npm:^3.1.0": version: 3.1.0 resolution: "expose-loader@npm:3.1.0" @@ -2323,6 +2464,19 @@ __metadata: languageName: node linkType: hard +"fast-glob@npm:3": + version: 3.2.7 + resolution: "fast-glob@npm:3.2.7" + dependencies: + "@nodelib/fs.stat": ^2.0.2 + "@nodelib/fs.walk": ^1.2.3 + glob-parent: ^5.1.2 + merge2: ^1.3.0 + micromatch: ^4.0.4 + checksum: 2f4708ff112d2b451888129fdd9a0938db88b105b0ddfd043c064e3c4d3e20eed8d7c7615f7565fee660db34ddcf08a2db1bf0ab3c00b87608e4719694642d78 + languageName: node + linkType: hard + "fast-json-stable-stringify@npm:^2.0.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" @@ -2337,6 +2491,24 @@ __metadata: languageName: node linkType: hard +"fastq@npm:^1.6.0": + version: 1.13.0 + resolution: "fastq@npm:1.13.0" + dependencies: + reusify: ^1.0.4 + checksum: 32cf15c29afe622af187d12fc9cd93e160a0cb7c31a3bb6ace86b7dea3b28e7b72acde89c882663f307b2184e14782c6c664fa315973c03626c7d4bff070bb0b + languageName: node + linkType: hard + +"fill-range@npm:^7.0.1": + version: 7.0.1 + resolution: "fill-range@npm:7.0.1" + dependencies: + to-regex-range: ^5.0.1 + checksum: cc283f4e65b504259e64fd969bcf4def4eb08d85565e906b7d36516e87819db52029a76b6363d0f02d0d532f0033c9603b9e2d943d56ee3b0d4f7ad3328ff917 + languageName: node + linkType: hard + "find-cache-dir@npm:^3.3.1": version: 3.3.2 resolution: "find-cache-dir@npm:3.3.2" @@ -2442,6 +2614,15 @@ __metadata: languageName: node linkType: hard +"glob-parent@npm:^5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: ^4.0.1 + checksum: f4f2bfe2425296e8a47e36864e4f42be38a996db40420fe434565e4480e3322f18eb37589617a98640c5dc8fdec1a387007ee18dbb1f3f5553409c34d17f425e + languageName: node + linkType: hard + "glob-to-regexp@npm:^0.4.1": version: 0.4.1 resolution: "glob-to-regexp@npm:0.4.1" @@ -2449,7 +2630,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.2": +"glob@npm:^7.1.2, glob@npm:^7.1.3": version: 7.2.0 resolution: "glob@npm:7.2.0" dependencies: @@ -2477,6 +2658,13 @@ __metadata: languageName: node linkType: hard +"gud@npm:^1.0.0": + version: 1.0.0 + resolution: "gud@npm:1.0.0" + checksum: 3e2eb37cf794364077c18f036d6aa259c821c7fd188f2b7935cb00d589d82a41e0ebb1be809e1a93679417f62f1ad0513e745c3cf5329596e489aef8c5e5feae + languageName: node + linkType: hard + "gzip-size@npm:^5.1.1": version: 5.1.1 resolution: "gzip-size@npm:5.1.1" @@ -2510,13 +2698,22 @@ __metadata: languageName: node linkType: hard -"has-symbols@npm:^1.0.1": +"has-symbols@npm:^1.0.1, has-symbols@npm:^1.0.2": version: 1.0.2 resolution: "has-symbols@npm:1.0.2" checksum: 2309c426071731be792b5be43b3da6fb4ed7cbe8a9a6bcfca1862587709f01b33d575ce8f5c264c1eaad09fca2f9a8208c0a2be156232629daa2dd0c0740976b languageName: node linkType: hard +"has-tostringtag@npm:^1.0.0": + version: 1.0.0 + resolution: "has-tostringtag@npm:1.0.0" + dependencies: + has-symbols: ^1.0.2 + checksum: cc12eb28cb6ae22369ebaad3a8ab0799ed61270991be88f208d508076a1e99abe4198c965935ce85ea90b60c94ddda73693b0920b58e7ead048b4a391b502c1c + languageName: node + linkType: hard + "has@npm:^1.0.3": version: 1.0.3 resolution: "has@npm:1.0.3" @@ -2576,6 +2773,16 @@ __metadata: languageName: node linkType: hard +"is-arguments@npm:^1.0.4": + version: 1.1.1 + resolution: "is-arguments@npm:1.1.1" + dependencies: + call-bind: ^1.0.2 + has-tostringtag: ^1.0.0 + checksum: 7f02700ec2171b691ef3e4d0e3e6c0ba408e8434368504bb593d0d7c891c0dbfda6d19d30808b904a6cb1929bca648c061ba438c39f296c2a8ca083229c49f27 + languageName: node + linkType: hard + "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" @@ -2592,6 +2799,38 @@ __metadata: languageName: node linkType: hard +"is-date-object@npm:^1.0.1": + version: 1.0.5 + resolution: "is-date-object@npm:1.0.5" + dependencies: + has-tostringtag: ^1.0.0 + checksum: baa9077cdf15eb7b58c79398604ca57379b2fc4cf9aa7a9b9e295278648f628c9b201400c01c5e0f7afae56507d741185730307cbe7cad3b9f90a77e5ee342fc + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: df033653d06d0eb567461e58a7a8c9f940bd8c22274b94bf7671ab36df5719791aae15eef6d83bbb5e23283967f2f984b8914559d4449efda578c775c4be6f85 + languageName: node + linkType: hard + +"is-glob@npm:^4.0.1": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: ^2.1.1 + checksum: d381c1319fcb69d341cc6e6c7cd588e17cd94722d9a32dbd60660b993c4fb7d0f19438674e68dfec686d09b7c73139c9166b47597f846af387450224a8101ab4 + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 456ac6f8e0f3111ed34668a624e45315201dff921e5ac181f8ec24923b99e9f32ca1a194912dc79d539c97d33dba17dc635202ff0b2cf98326f608323276d27a + languageName: node + linkType: hard + "is-plain-object@npm:^2.0.4": version: 2.0.4 resolution: "is-plain-object@npm:2.0.4" @@ -2601,6 +2840,16 @@ __metadata: languageName: node linkType: hard +"is-regex@npm:^1.0.4": + version: 1.1.4 + resolution: "is-regex@npm:1.1.4" + dependencies: + call-bind: ^1.0.2 + has-tostringtag: ^1.0.0 + checksum: 362399b33535bc8f386d96c45c9feb04cf7f8b41c182f54174c1a45c9abbbe5e31290bbad09a458583ff6bf3b2048672cdb1881b13289569a7c548370856a652 + languageName: node + linkType: hard + "is-stream@npm:^2.0.0": version: 2.0.1 resolution: "is-stream@npm:2.0.1" @@ -2647,7 +2896,7 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^4.0.0": +"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" checksum: 8a95213a5a77deb6cbe94d86340e8d9ace2b93bc367790b260101d2f36a2eaf4e4e22d9fa9cf459b38af3a32fb4190e638024cf82ec95ef708680e405ea7cc78 @@ -2722,6 +2971,13 @@ __metadata: languageName: node linkType: hard +"keyboard-key@npm:^1.0.4": + version: 1.1.0 + resolution: "keyboard-key@npm:1.1.0" + checksum: 8bf7c0d796820a0d4802930ef103339e2ffddf369f441d8e19f0a1d3b841da71a0a4da8c7a5270f4814da54300434f5fc5b3489aae4ae3ba47418ac106b71a64 + languageName: node + linkType: hard + "kind-of@npm:^6.0.2": version: 6.0.3 resolution: "kind-of@npm:6.0.3" @@ -2777,13 +3033,24 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.20": +"lodash@npm:^4.17.15, lodash@npm:^4.17.20": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 languageName: node linkType: hard +"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": + version: 1.4.0 + resolution: "loose-envify@npm:1.4.0" + dependencies: + js-tokens: ^3.0.0 || ^4.0.0 + bin: + loose-envify: cli.js + checksum: 6517e24e0cad87ec9888f500c5b5947032cdfe6ef65e1c1936a0c48a524b81e65542c9c3edc91c97d5bddc806ee2a985dbc79be89215d613b1de5db6d1cfe6f4 + languageName: node + linkType: hard + "make-dir@npm:^3.0.2, make-dir@npm:^3.1.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -2800,6 +3067,23 @@ __metadata: languageName: node linkType: hard +"merge2@npm:^1.3.0": + version: 1.4.1 + resolution: "merge2@npm:1.4.1" + checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2 + languageName: node + linkType: hard + +"micromatch@npm:^4.0.4": + version: 4.0.4 + resolution: "micromatch@npm:4.0.4" + dependencies: + braces: ^3.0.1 + picomatch: ^2.2.3 + checksum: ef3d1c88e79e0a68b0e94a03137676f3324ac18a908c245a9e5936f838079fcc108ac7170a5fadc265a9c2596963462e402841406bda1a4bb7b68805601d631c + languageName: node + linkType: hard + "mime-db@npm:1.50.0": version: 1.50.0 resolution: "mime-db@npm:1.50.0" @@ -2832,7 +3116,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.4": +"minimatch@npm:3, minimatch@npm:^3.0.4": version: 3.0.4 resolution: "minimatch@npm:3.0.4" dependencies: @@ -2873,6 +3157,15 @@ __metadata: languageName: node linkType: hard +"ncp@npm:^2.0.0": + version: 2.0.0 + resolution: "ncp@npm:2.0.0" + bin: + ncp: ./bin/ncp + checksum: ea9b19221da1d1c5529bdb9f8e85c9d191d156bcaae408cce5e415b7fbfd8744c288e792bd7faf1fe3b70fd44c74e22f0d43c39b209bc7ac1fb8016f70793a16 + languageName: node + linkType: hard + "neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" @@ -2899,6 +3192,13 @@ __metadata: languageName: node linkType: hard +"normalize-path@npm:3": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: 88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20 + languageName: node + linkType: hard + "npm-run-path@npm:^4.0.1": version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" @@ -2908,6 +3208,23 @@ __metadata: languageName: node linkType: hard +"object-assign@npm:^4.1.1": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f + languageName: node + linkType: hard + +"object-is@npm:^1.0.1": + version: 1.1.5 + resolution: "object-is@npm:1.1.5" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.3 + checksum: 989b18c4cba258a6b74dc1d74a41805c1a1425bce29f6cabb50dcb1a6a651ea9104a1b07046739a49a5bb1bc49727bcb00efd5c55f932f6ea04ec8927a7901fe + languageName: node + linkType: hard + "object-keys@npm:^1.0.12, object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -3035,6 +3352,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^2.2.3": + version: 2.3.0 + resolution: "picomatch@npm:2.3.0" + checksum: 16818720ea7c5872b6af110760dee856c8e4cd79aed1c7a006d076b1cc09eff3ae41ca5019966694c33fbd2e1cc6ea617ab10e4adac6df06556168f13be3fca2 + languageName: node + linkType: hard + "pify@npm:^4.0.1": version: 4.0.1 resolution: "pify@npm:4.0.1" @@ -3051,6 +3375,13 @@ __metadata: languageName: node linkType: hard +"popper.js@npm:^1.14.4": + version: 1.16.1 + resolution: "popper.js@npm:1.16.1" + checksum: c56ae5001ec50a77ee297a8061a0221d99d25c7348d2e6bcd3e45a0d0f32a1fd81bca29d46cb0d4bdf13efb77685bd6a0ce93f9eb3c608311a461f945fffedbe + languageName: node + linkType: hard + "prettier@npm:^2.4.1": version: 2.4.1 resolution: "prettier@npm:2.4.1" @@ -3060,6 +3391,17 @@ __metadata: languageName: node linkType: hard +"prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2": + version: 15.7.2 + resolution: "prop-types@npm:15.7.2" + dependencies: + loose-envify: ^1.4.0 + object-assign: ^4.1.1 + react-is: ^16.8.1 + checksum: 5eef82fdda64252c7e75aa5c8cc28a24bbdece0f540adb60ce67c205cf978a5bd56b83e4f269f91c6e4dcfd80b36f2a2dec24d362e278913db2086ca9c6f9430 + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.1.1": version: 2.1.1 resolution: "punycode@npm:2.1.1" @@ -3067,6 +3409,13 @@ __metadata: languageName: node linkType: hard +"queue-microtask@npm:^1.2.2": + version: 1.2.3 + resolution: "queue-microtask@npm:1.2.3" + checksum: b676f8c040cdc5b12723ad2f91414d267605b26419d5c821ff03befa817ddd10e238d22b25d604920340fd73efd8ba795465a0377c4adf45a4a41e4234e42dc4 + languageName: node + linkType: hard + "randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -3076,6 +3425,55 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:^16.13.1": + version: 16.14.0 + resolution: "react-dom@npm:16.14.0" + dependencies: + loose-envify: ^1.1.0 + object-assign: ^4.1.1 + prop-types: ^15.6.2 + scheduler: ^0.19.1 + peerDependencies: + react: ^16.14.0 + checksum: 5a5c49da0f106b2655a69f96c622c347febcd10532db391c262b26aec225b235357d9da1834103457683482ab1b229af7a50f6927a6b70e53150275e31785544 + languageName: node + linkType: hard + +"react-is@npm:^16.6.3, react-is@npm:^16.8.1, react-is@npm:^16.8.6": + version: 16.13.1 + resolution: "react-is@npm:16.13.1" + checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f + languageName: node + linkType: hard + +"react-popper@npm:^1.3.4": + version: 1.3.11 + resolution: "react-popper@npm:1.3.11" + dependencies: + "@babel/runtime": ^7.1.2 + "@hypnosphi/create-react-context": ^0.3.1 + deep-equal: ^1.1.1 + popper.js: ^1.14.4 + prop-types: ^15.6.1 + typed-styles: ^0.0.7 + warning: ^4.0.2 + peerDependencies: + react: 0.14.x || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: a0f5994f5799f1c7364498f74df123dd2561fff4ae834b10fdcca74d9a8e159b523ed1f0708db33bad606933ab4f0d5ce9c90e48cbb671bf30016c890f3c7ea4 + languageName: node + linkType: hard + +"react@npm:^16.13.1": + version: 16.14.0 + resolution: "react@npm:16.14.0" + dependencies: + loose-envify: ^1.1.0 + object-assign: ^4.1.1 + prop-types: ^15.6.2 + checksum: 8484f3ecb13414526f2a7412190575fc134da785c02695eb92bb6028c930bfe1c238d7be2a125088fec663cc7cda0a3623373c46807cf2c281f49c34b79881ac + languageName: node + linkType: hard + "read-pkg-up@npm:^7.0.1": version: 7.0.1 resolution: "read-pkg-up@npm:7.0.1" @@ -3140,6 +3538,16 @@ __metadata: languageName: node linkType: hard +"regexp.prototype.flags@npm:^1.2.0": + version: 1.3.1 + resolution: "regexp.prototype.flags@npm:1.3.1" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.3 + checksum: 343595db5a6bbbb3bfbda881f9c74832cfa9fc0039e64a43843f6bb9158b78b921055266510800ed69d4997638890b17a46d55fd9f32961f53ae56ac3ec4dd05 + languageName: node + linkType: hard + "regexpu-core@npm:^4.7.1": version: 4.8.0 resolution: "regexpu-core@npm:4.8.0" @@ -3208,6 +3616,33 @@ __metadata: languageName: node linkType: hard +"reusify@npm:^1.0.4": + version: 1.0.4 + resolution: "reusify@npm:1.0.4" + checksum: c3076ebcc22a6bc252cb0b9c77561795256c22b757f40c0d8110b1300723f15ec0fc8685e8d4ea6d7666f36c79ccc793b1939c748bf36f18f542744a4e379fcc + languageName: node + linkType: hard + +"rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "rimraf@npm:3.0.2" + dependencies: + glob: ^7.1.3 + bin: + rimraf: bin.js + checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0 + languageName: node + linkType: hard + +"run-parallel@npm:^1.1.9": + version: 1.2.0 + resolution: "run-parallel@npm:1.2.0" + dependencies: + queue-microtask: ^1.2.2 + checksum: cb4f97ad25a75ebc11a8ef4e33bb962f8af8516bb2001082ceabd8902e15b98f4b84b4f8a9b222e5d57fc3bd1379c483886ed4619367a7680dad65316993021d + languageName: node + linkType: hard + "safe-buffer@npm:^5.1.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -3222,6 +3657,16 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.19.1": + version: 0.19.1 + resolution: "scheduler@npm:0.19.1" + dependencies: + loose-envify: ^1.1.0 + object-assign: ^4.1.1 + checksum: 73e185a59e2ff5aa3609f5b9cb97ddd376f89e1610579d29939d952411ca6eb7a24907a4ea4556569dacb931467a1a4a56d94fe809ef713aa76748642cd96a6c + languageName: node + linkType: hard + "schema-utils@npm:^2.6.5": version: 2.7.1 resolution: "schema-utils@npm:2.7.1" @@ -3244,6 +3689,28 @@ __metadata: languageName: node linkType: hard +"semantic-ui-react@npm:^0.88.2": + version: 0.88.2 + resolution: "semantic-ui-react@npm:0.88.2" + dependencies: + "@babel/runtime": ^7.1.2 + "@semantic-ui-react/event-stack": ^3.1.0 + "@stardust-ui/react-component-event-listener": ~0.38.0 + "@stardust-ui/react-component-ref": ~0.38.0 + classnames: ^2.2.6 + keyboard-key: ^1.0.4 + lodash: ^4.17.15 + prop-types: ^15.7.2 + react-is: ^16.8.6 + react-popper: ^1.3.4 + shallowequal: ^1.1.0 + peerDependencies: + react: ^16.8.0 + react-dom: ^16.8.0 + checksum: b7d6d46ec2f8a08c50c5831977b2df8d7849cd5660d99d18be93e191853bb5259a0912e66c4d882e463ade4eacec62462f817b3c6000a65a6c63fbe17d6bd262 + languageName: node + linkType: hard + "semver@npm:2 || 3 || 4 || 5": version: 5.7.1 resolution: "semver@npm:5.7.1" @@ -3289,6 +3756,13 @@ __metadata: languageName: node linkType: hard +"shallowequal@npm:^1.1.0": + version: 1.1.0 + resolution: "shallowequal@npm:1.1.0" + checksum: f4c1de0837f106d2dbbfd5d0720a5d059d1c66b42b580965c8f06bb1db684be8783538b684092648c981294bf817869f743a066538771dbecb293df78f765e00 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -3429,6 +3903,16 @@ __metadata: languageName: node linkType: hard +"terminal-table@npm:^0.0.12": + version: 0.0.12 + resolution: "terminal-table@npm:0.0.12" + dependencies: + colors: ^1.0.3 + eastasianwidth: ^0.1.0 + checksum: ea678bf685170b228e5f36b83ff2a4e979b4664245431583a327f0c87ca64292e52f582fd5c869cf593249b5e5df7fc7113444c91b47da20ac1a6c914a6cba59 + languageName: node + linkType: hard + "terser-webpack-plugin@npm:^5.1.3": version: 5.2.4 resolution: "terser-webpack-plugin@npm:5.2.4" @@ -3486,6 +3970,15 @@ __metadata: languageName: node linkType: hard +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: ^7.0.0 + checksum: f76fa01b3d5be85db6a2a143e24df9f60dd047d151062d0ba3df62953f2f697b16fe5dad9b0ac6191c7efc7b1d9dcaa4b768174b7b29da89d4428e64bc0a20ed + languageName: node + linkType: hard + "totalist@npm:^1.0.0": version: 1.1.0 resolution: "totalist@npm:1.1.0" @@ -3493,6 +3986,46 @@ __metadata: languageName: node linkType: hard +"tslib@npm:1 || 2": + version: 2.3.1 + resolution: "tslib@npm:2.3.1" + checksum: de17a98d4614481f7fcb5cd53ffc1aaf8654313be0291e1bfaee4b4bb31a20494b7d218ff2e15017883e8ea9626599b3b0e0229c18383ba9dce89da2adf15cb9 + languageName: node + linkType: hard + +"tslib@npm:^1.8.1": + version: 1.14.1 + resolution: "tslib@npm:1.14.1" + checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd + languageName: node + linkType: hard + +"tsutils@npm:3": + version: 3.21.0 + resolution: "tsutils@npm:3.21.0" + dependencies: + tslib: ^1.8.1 + peerDependencies: + typescript: ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + checksum: 1843f4c1b2e0f975e08c4c21caa4af4f7f65a12ac1b81b3b8489366826259323feb3fc7a243123453d2d1a02314205a7634e048d4a8009921da19f99755cdc48 + languageName: node + linkType: hard + +"type-coverage-core@npm:^2.17.2": + version: 2.18.3 + resolution: "type-coverage-core@npm:2.18.3" + dependencies: + fast-glob: 3 + minimatch: 3 + normalize-path: 3 + tslib: 1 || 2 + tsutils: 3 + peerDependencies: + typescript: 2 || 3 || 4 + checksum: ba9f42fb9c71f49124bc0422dd14e7396806be1f4d4ead4af37d598d143cc03cb1fd5b951b685307f43a830d5288bf38ea0a8b5a3a40db5e87e7428d7430e939 + languageName: node + linkType: hard + "type-fest@npm:^0.6.0": version: 0.6.0 resolution: "type-fest@npm:0.6.0" @@ -3507,6 +4040,43 @@ __metadata: languageName: node linkType: hard +"typed-styles@npm:^0.0.7": + version: 0.0.7 + resolution: "typed-styles@npm:0.0.7" + checksum: 36a6ad6bee008c15ddb8c2425eaf9aee37d2841985b4c44406ea4cf57080a9c30b6f9f3feb842ac952354733ac53299ee44f68d83f734486e8344d413f8c8c0d + languageName: node + linkType: hard + +"typescript-coverage-report@npm:^0.6.1": + version: 0.6.1 + resolution: "typescript-coverage-report@npm:0.6.1" + dependencies: + colors: ^1.4.0 + commander: ^5.0.0 + ncp: ^2.0.0 + react: ^16.13.1 + react-dom: ^16.13.1 + rimraf: ^3.0.2 + semantic-ui-react: ^0.88.2 + terminal-table: ^0.0.12 + type-coverage-core: ^2.17.2 + typescript: 4.1.3 + bin: + typescript-coverage-report: dist/bin/typescript-coverage-report.js + checksum: ee3703a56d53aeba723ca65563834becfa2e8ce67cf51fc4c839191db7215c4704f0affc3ca3095ff6baab724c5b0312c84af828e5f199ea6ffe49d1bf223400 + languageName: node + linkType: hard + +"typescript@npm:4.1.3": + version: 4.1.3 + resolution: "typescript@npm:4.1.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 0a25f7d7cebbc5ad23f41cb30918643460477be265bd3bcd400ffedb77d16e97d46f2b0c31393b2f990c5cf5b9f7a829ad6aff8636988b8f30abf81c656237c0 + languageName: node + linkType: hard + "typescript@npm:^4.3.2, typescript@npm:^4.4.4": version: 4.4.4 resolution: "typescript@npm:4.4.4" @@ -3517,6 +4087,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@4.1.3#~builtin": + version: 4.1.3 + resolution: "typescript@patch:typescript@npm%3A4.1.3#~builtin::version=4.1.3&hash=ddd1e8" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: ed3df76d9b6efb448e5e73bca671698cda353a3807199ad95c78782a857e7685ccc94b799ac885423ced65ee21c87cbbeb823642c5dfd715efae5ebbc6137070 + languageName: node + linkType: hard + "typescript@patch:typescript@^4.3.2#~builtin, typescript@patch:typescript@^4.4.4#~builtin": version: 4.4.4 resolution: "typescript@patch:typescript@npm%3A4.4.4#~builtin::version=4.4.4&hash=ddd1e8" @@ -3577,6 +4157,15 @@ __metadata: languageName: node linkType: hard +"warning@npm:^4.0.2, warning@npm:^4.0.3": + version: 4.0.3 + resolution: "warning@npm:4.0.3" + dependencies: + loose-envify: ^1.0.0 + checksum: 4f2cb6a9575e4faf71ddad9ad1ae7a00d0a75d24521c193fa464f30e6b04027bd97aa5d9546b0e13d3a150ab402eda216d59c1d0f2d6ca60124d96cd40dfa35c + languageName: node + linkType: hard + "watchpack@npm:^2.2.0": version: 2.2.0 resolution: "watchpack@npm:2.2.0"