diff --git a/package-lock.json b/package-lock.json index 010c4f2..dbd02c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@mui/styles": "^5.11.9", "@rjsf/validator-ajv8": "^5.1.0", "@types/lodash": "^4.14.202", + "@types/moment-duration-format": "^2.2.7", "@types/node": "^20.11.30", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", @@ -5722,6 +5723,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/moment-duration-format": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@types/moment-duration-format/-/moment-duration-format-2.2.7.tgz", + "integrity": "sha512-BSxkbKI8ucqqxi4ZCfmFPilUDPoL85YVrOvn/AhCQoeqNfnRsRLGkk68TpoC7L6GwU27RltYiQV5dtfPTv/RHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "moment": ">=2.14.0" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", diff --git a/package.json b/package.json index 890f014..6c809e1 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@mui/styles": "^5.11.9", "@rjsf/validator-ajv8": "^5.1.0", "@types/lodash": "^4.14.202", + "@types/moment-duration-format": "^2.2.7", "@types/node": "^20.11.30", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", diff --git a/src/components/Compute.jsx b/src/components/Compute.tsx similarity index 69% rename from src/components/Compute.jsx rename to src/components/Compute.tsx index 799850e..b1bebc3 100644 --- a/src/components/Compute.jsx +++ b/src/components/Compute.tsx @@ -1,22 +1,56 @@ -/* eslint-disable react/require-default-props */ /* eslint-disable jsx-a11y/anchor-is-valid */ -/* eslint-disable react/prop-types */ import Dropdown from "@mat3ra/cove/dist/mui/components/dropdown"; import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; import { showWarningAlert } from "@mat3ra/cove/dist/other/alerts"; import Box from "@mui/material/Box"; import { styled } from "@mui/material/styles"; import setClass from "classnames"; -import PropTypes from "prop-types"; import React from "react"; import { ComputeForm } from "./ComputeForm"; -import { StatusTrackTable } from "./StatusTrackTable"; +import type { AccountUser } from "./Notify"; +import { StatusTrackTable, StatusTrackEntry } from "./StatusTrackTable"; import EntityHeader from "@mat3ra/cove/dist/mui-composed/components/entity-header/EntityHeader"; +import type { Account, ClusterNode, CoreUser } from "./ComputeForm"; + +/** Minimal shape `Compute` needs off the host's job entity. */ +export interface ComputeJob { + statusTrack?: unknown[]; + statusTrackSorted: StatusTrackEntry[]; + usedApplicationNames: string[]; +} + +interface ComputeProps { + className?: string; + showHeader?: boolean; + isLoading?: boolean; + adjustable?: boolean; + editable?: boolean; + showComputeForm?: boolean; + showStatusTrack?: boolean; + compute: any; + user: CoreUser; + account: Account; + clusters: ClusterNode[]; + onUpdate: (s: string) => void; + job: ComputeJob; + showAdvancedOptions?: boolean; + accountUsers: AccountUser[]; + isAccountUsersLoading: boolean; +} + +interface ComputeState { + isAutoSet: boolean; +} const DropdownButton = styled("div")(({ theme }) => ({ - border: `1px solid ${theme.palette.border?.dark ?? theme.palette.divider}`, + // `theme.palette.border` is a real @mat3ra/cove theme augmentation (`src/theme/mui.d.ts`), + // but cove only ships its `dist/` build - the augmentation file itself isn't published, so + // ive's own compilation can't see it. Cast locally rather than treat it as dead code. + border: `1px solid ${ + (theme.palette as { border?: { dark?: string } }).border?.dark ?? theme.palette.divider + }`, borderRadius: "4px", padding: theme.spacing(1), width: "40px", @@ -38,8 +72,16 @@ const EntityHeaderContainer = styled("div")(() => ({ width: "100%", })); -class Compute extends React.Component { - constructor(props) { +class Compute extends React.Component { + static defaultProps = { + editable: true, + showHeader: true, + clusters: [], + showComputeForm: true, + showStatusTrack: true, + }; + + constructor(props: ComputeProps) { super(props); this.state = { isAutoSet: false, @@ -100,8 +142,6 @@ class Compute extends React.Component { icon="pages.compute" isLoading={isLoading} editable={false} - adjustable - isDescriptionEditorHidden /> {adjustable || editable ? ( @@ -116,14 +156,14 @@ class Compute extends React.Component { ) : null} {showComputeForm && ( @@ -138,27 +178,4 @@ class Compute extends React.Component { } } -Compute.propTypes = { - editable: PropTypes.bool, - compute: PropTypes.object, - job: PropTypes.object, - user: PropTypes.object, - account: PropTypes.object, - clusters: PropTypes.array, - onUpdate: PropTypes.func, - showComputeForm: PropTypes.bool, - showStatusTrack: PropTypes.bool, - showAdvancedOptions: PropTypes.bool, - accountUsers: PropTypes.array, -}; - -Compute.defaultProps = { - editable: true, - // eslint-disable-next-line react/default-props-match-prop-types - showHeader: true, - clusters: [], - showComputeForm: true, - showStatusTrack: true, -}; - export default Compute; diff --git a/src/components/ComputeForm.tsx b/src/components/ComputeForm.tsx index b988010..8c56be8 100644 --- a/src/components/ComputeForm.tsx +++ b/src/components/ComputeForm.tsx @@ -28,7 +28,7 @@ import omitBy from "lodash/omitBy"; import React from "react"; import { getComputeSchema, getComputeValidator } from "../validators"; -import Notify from "./Notify"; +import Notify, { AccountUser } from "./Notify"; import QueuesTable from "./QueuesTable"; import { LoadingIndicator } from "@mat3ra/cove/dist/mui-composed/components/loading/LoadingIndicator"; @@ -299,7 +299,7 @@ function resolveComputeUISchema(appName: string): UISchema { interface ComputeFormProps { user: CoreUser; account: Account; - accountUsers: CoreUser[]; + accountUsers: AccountUser[]; clusters: ClusterNode[]; isAccountUsersLoading: boolean; showAdvancedOptions: boolean; @@ -453,7 +453,6 @@ export class ComputeForm extends React.Component ) : ( - class extends StatefulEntityMixin(superclass) { - constructor(props) { - super(props); - this.onComputeUpdate = this.onComputeUpdate.bind(this); - this.onComputeToggle = this.onComputeToggle.bind(this); - } - - onComputeUpdate(compute) { - this.state.entity.setCompute(compute); - this._resetStateEntityAndUpdateParents(this.state.entity); - } - - onComputeToggle(checked) { - if (checked) { - this.state.entity.setCompute(this.constructor.getDefaultComputeConfig()); - } else { - this.state.entity.unsetCompute(); - } - this._resetStateEntityAndUpdateParents(this.state.entity); - } - }; diff --git a/src/components/Notify.jsx b/src/components/Notify.tsx similarity index 88% rename from src/components/Notify.jsx rename to src/components/Notify.tsx index b2a45b0..389ab4d 100644 --- a/src/components/Notify.jsx +++ b/src/components/Notify.tsx @@ -1,9 +1,7 @@ -/* eslint-disable react/require-default-props */ /* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/anchor-is-valid */ -/* eslint-disable react/prop-types */ import { EMAIL_NOTIFICATIONS } from "@mat3ra/ide"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; @@ -14,14 +12,35 @@ import InputLabel from "@mui/material/InputLabel"; import MenuItem from "@mui/material/MenuItem"; import OutlinedInput from "@mui/material/OutlinedInput"; import Paper from "@mui/material/Paper"; -import Select from "@mui/material/Select"; +import Select, { SelectChangeEvent } from "@mui/material/Select"; import Typography from "@mui/material/Typography"; import setClass from "classnames"; -import PropTypes from "prop-types"; import React from "react"; import AccountCard from "@mat3ra/cove/dist/mui-composed/components/account/AccountCard"; +/** Minimal shape of an account-user entry, as passed from the host application. */ +export interface AccountUser { + entity: { id: string | number; email: string; [key: string]: unknown }; + account: { entity: { name?: string; [key: string]: unknown } }; +} + +interface NotifyProps { + notify?: string; + email?: string; + accountUsers: AccountUser[]; + editable?: boolean; + onUpdate: (payload: { notify: string; email: string }) => void; +} + +interface NotifyState { + notify: string; + isBegin: boolean; + isAbort: boolean; + isEnd: boolean; + selectedUsers: AccountUser[]; +} + const IS_BEGIN = "isBegin"; const IS_ABORT = "isAbort"; const IS_END = "isEnd"; @@ -36,8 +55,12 @@ const MenuProps = { }, }; -class Notify extends React.Component { - constructor(props) { +class Notify extends React.Component { + static defaultProps = { + editable: true, + }; + + constructor(props: NotifyProps) { super(props); const { notify = "", email, accountUsers } = this.props; @@ -57,10 +80,8 @@ class Notify extends React.Component { }; } - selectNotifyAccount = (event) => { - const { - target: { value: selectedUsers }, - } = event; + selectNotifyAccount = (event: SelectChangeEvent) => { + const selectedUsers = event.target.value as AccountUser[]; const { editable, onUpdate } = this.props; const { isBegin, isAbort, isEnd } = this.state; @@ -113,17 +134,17 @@ class Notify extends React.Component { }, () => { const { notify, selectedUsers: users } = this.state; - onUpdate({ notify, email: users.map((user) => user.email).join(",") }); + onUpdate({ notify, email: users.map((user) => user.entity.email).join(",") }); }, ); } }; - toggleOption(optionName) { + toggleOption(optionName: string) { const { onUpdate } = this.props; const { selectedUsers } = this.state; const isSelectedUsers = !!selectedUsers.length; - let updatedState; + let updatedState: Partial; if (!isSelectedUsers) { return; @@ -143,7 +164,7 @@ class Notify extends React.Component { throw new Error(`Not supported optionName ${optionName}`); } - this.setState(updatedState, () => { + this.setState((prevState) => ({ ...prevState, ...updatedState }), () => { const { notify, selectedUsers: users } = this.state; onUpdate({ notify: users.length ? notify : EMAIL_NOTIFICATIONS.never, @@ -224,14 +245,16 @@ class Notify extends React.Component { onChange={this.selectNotifyAccount} input={} renderValue={(selected) => { - return selected.map((user) => user.account.entity.name).join(", "); + return (selected as AccountUser[]) + .map((user) => user.account.entity.name) + .join(", "); }} MenuProps={MenuProps} disabled={!editable}> {accountUsers.map((item) => ( { display: string }; } diff --git a/src/components/StatusTrackTable.jsx b/src/components/StatusTrackTable.tsx similarity index 82% rename from src/components/StatusTrackTable.jsx rename to src/components/StatusTrackTable.tsx index 2368992..7cc4815 100644 --- a/src/components/StatusTrackTable.jsx +++ b/src/components/StatusTrackTable.tsx @@ -8,27 +8,32 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import moment from "moment"; -import PropTypes from "prop-types"; import React, { Component } from "react"; import capitalize from "underscore.string/capitalize"; import "moment-duration-format"; -export class StatusTrackTable extends Component { - constructor(props) { - super(props); - this.state = {}; - } +export interface StatusTrackEntry { + trackedAt: number | string; + [key: string]: unknown; +} + +interface StatusTrackTableProps { + entity: { + statusTrackSorted: StatusTrackEntry[]; + }; +} +export class StatusTrackTable extends Component { // adds time delta (duration) for any subsequent status changes get statusTrackWithTimeDelta() { const { entity } = this.props; return entity.statusTrackSorted.map((entry, index, array) => { - let duration = "-"; + let duration: string = "-"; const nextEntry = array[index + 1]; if (nextEntry) { duration = moment - .duration(nextEntry.trackedAt - entry.trackedAt, "milliseconds") + .duration(Number(nextEntry.trackedAt) - Number(entry.trackedAt), "milliseconds") .format("h[h] m[m] s[s]"); } return { ...entry, duration }; @@ -62,10 +67,10 @@ export class StatusTrackTable extends Component { {Object.values(entry).map((value, idx) => ( {tableHeaders[idx] === "trackedAt" - ? moment(value).format( + ? moment(value as string | number).format( "dddd, MMMM Do YYYY, h:mm:ss a", ) - : value} + : (value as React.ReactNode)} ))} @@ -77,10 +82,3 @@ export class StatusTrackTable extends Component { ); } } - -StatusTrackTable.propTypes = { - // eslint-disable-next-line react/require-default-props - entity: PropTypes.object, -}; - -StatusTrackTable.defaultProps = {}; diff --git a/src/components/mixins.jsx b/src/components/mixins.tsx similarity index 64% rename from src/components/mixins.jsx rename to src/components/mixins.tsx index 33b013d..9dfdcbb 100644 --- a/src/components/mixins.jsx +++ b/src/components/mixins.tsx @@ -2,10 +2,40 @@ import Alert from "@mui/material/Alert"; import React from "react"; -export const ComputableEntityMixin = (superclass) => +/** Shape of a single backend-reported compute error, as rendered by `renderErrors()`. */ +export interface ComputeError { + message: string; + reason?: string; + traceback?: string; +} + +/** Shape of a single "on-the-fly" warning, as rendered by `renderWarnings()`. */ +export interface WarningConfig { + condition: boolean; + message: React.ReactNode; +} + +/** + * What `computedEntity` needs to provide. Mirrors `@mat3ra/ide`'s real + * `ComputedEntityMixin` (`errors`, always populated by the mixin `ide` applies to + * jode's `Job.prototype`) - but `warnings` is optional, matching reality: `ide` dropped + * its `warnings` fallback (nothing replaced it - the intended web-app follow-up never + * landed, and the file it would have landed in was later deleted entirely), so no + * producer of `.warnings` exists anywhere in the stack today. + */ +export interface ComputableEntity { + readonly errors: ComputeError[]; + readonly warnings?: WarningConfig[]; +} + +type Constructor = new (...args: any[]) => T; + +export const ComputableEntityMixin = (superclass: TBase) => class extends superclass { - constructor(props) { - super(props); + state: any; + + constructor(...args: any[]) { + super(...args); this.state = { ...this.state, dismissWarningAlerts: { @@ -18,12 +48,12 @@ export const ComputableEntityMixin = (superclass) => this.handleErrorAlertDismiss = this.handleErrorAlertDismiss.bind(this); } - shouldComponentUpdateFromComputableEntityMixin(nextProps, nextState) { + shouldComponentUpdateFromComputableEntityMixin(nextProps: any, nextState: any) { // to calculate the number of (dismissed) alerts in the state const { dismissErrorAlerts, dismissWarningAlerts } = this.state; - const stateObjectToNumber = (object) => + const stateObjectToNumber = (object: Record) => Object.values(object) - .map((v) => (v === true ? 1 : 0)) + .map((v): number => (v === true ? 1 : 0)) .reduce((a, b) => a + b, 0); return !( stateObjectToNumber(dismissErrorAlerts) === @@ -33,7 +63,7 @@ export const ComputableEntityMixin = (superclass) => ); } - handleWarningAlertDismiss(key) { + handleWarningAlertDismiss(key: number) { this.setState({ dismissWarningAlerts: { [key]: true, @@ -41,7 +71,7 @@ export const ComputableEntityMixin = (superclass) => }); } - handleErrorAlertDismiss(key) { + handleErrorAlertDismiss(key: number) { this.setState({ dismissErrorAlerts: { [key]: true, @@ -50,12 +80,12 @@ export const ComputableEntityMixin = (superclass) => } // override upon mixing - get computedEntity() { + get computedEntity(): ComputableEntity { throw new Error("Not implemented."); } // errors come from backend - renderErrors() { + renderErrors(): React.ReactNode { const notDismissedErrors = this.computedEntity.errors.filter( (e, idx) => !this.state.dismissErrorAlerts[idx], ); @@ -80,9 +110,9 @@ export const ComputableEntityMixin = (superclass) => : null; } - // warnings are calculated "on-the-fly" - renderWarnings() { - const notDismissedWarnings = this.computedEntity.warnings.filter( + // warnings are calculated "on-the-fly" - optional, see `ComputableEntity` above + renderWarnings(): React.ReactNode { + const notDismissedWarnings = (this.computedEntity.warnings ?? []).filter( (e, idx) => !this.state.dismissWarningAlerts[idx], ); return notDismissedWarnings.map((warningConfig, idx) => { diff --git a/src/modules.d.ts b/src/modules.d.ts index a343829..d03c4a7 100644 --- a/src/modules.d.ts +++ b/src/modules.d.ts @@ -1,2 +1,5 @@ declare module "@mat3ra/ide"; declare module "flat"; +declare module "underscore.string/capitalize" { + export default function capitalize(str: string): string; +} diff --git a/src/utils/clusters_load.ts b/src/utils/clusters_load.ts index c17fbfd..b54a8b0 100644 --- a/src/utils/clusters_load.ts +++ b/src/utils/clusters_load.ts @@ -26,7 +26,7 @@ function calculateLoad(load: number) { return "high"; } -function getStatus(load: string, capacity: string) { +function getStatus(load: string, capacity: string | undefined) { switch (`${load}/${capacity}`) { case "low/FULL": return LOAD_STATUSES.low; @@ -55,7 +55,7 @@ function getStatus(load: string, capacity: string) { } export const ClustersLoadHandler = { - queueStatus(load: number, capacity: string) { + queueStatus(load: number, capacity: string | undefined) { const calculatedLoad = calculateLoad(load); return getStatus(calculatedLoad, capacity); }, diff --git a/src/validators.js b/src/validators.ts similarity index 70% rename from src/validators.js rename to src/validators.ts index 4917a0c..ed50a99 100644 --- a/src/validators.js +++ b/src/validators.ts @@ -13,7 +13,7 @@ const defaultCluster = { hostname: "localhost" }; * @param hostname {String} hostname * @returns {*} node data */ -const getNodeByHostname = (hostname) => { +const getNodeByHostname = (hostname: string) => { return { hostname, queues: [ @@ -30,16 +30,20 @@ const getNodeByHostname = (hostname) => { /** * @summary Custom PPN validator - * @param ppn {Number} processors per node - * @param dataPath {String} dot-delimited path to data in schema - * @param data {Object} the current "form" state - * @returns {boolean} successful validation + * + * Registered on the `validatePpn` ajv keyword below, but that keyword is never referenced + * by any schema in `src/schemas/ui/` or esse's `compute` schemas - dead code, ajv never + * actually invokes this. Its 3-arg signature doesn't match ajv v8's real `schema: false` + * custom-keyword contract either (`(data, dataCxt)` - two args, no third `data` param; + * see `node_modules/ajv/dist/vocabularies/code.js`'s `callValidateCode`), which is only + * possible to say for certain because it's unreachable - left as-is rather than guessing + * at intended behavior for a path nothing exercises. */ -const validatePpn = (ppn, dataPath, data) => { +const validatePpn = (ppn: number, dataPath: unknown, data: Record = {}) => { const { queue: queueName, node } = data; // mock method doesn't return Queue objects so name -> NAME && maxPPN -> MAX-PPN const queue = node - ? node.queues.find((q) => q.name === queueName || q.NAME === queueName) + ? node.queues.find((q: Record) => q.name === queueName || q.NAME === queueName) : undefined; const maxPPN = queue ? queue.maxPPN || queue["MAX-PPN"] : 1; if (ppn > maxPPN) return false; @@ -79,12 +83,10 @@ const maxTenNodesQueueTypeList = [ /** * @summary Custom node validator - * @param nodes {Number} number of nodes - * @param dataPath {String} dot-delimited path to data in schema - * @param data {Object} the current "form" state - * @returns {boolean} successful validation + * + * Same "registered but never referenced by any schema" situation as `validatePpn` above. */ -const validateNodes = (nodes, dataPath, data) => { +const validateNodes = (nodes: number, dataPath: unknown, data: Record = {}) => { const { queue } = data; if (oneNodeQueueTypeList.includes(queue) && nodes !== 1) { @@ -99,18 +101,20 @@ const validateNodes = (nodes, dataPath, data) => { }; // TODO : should get available number of nodes from backend side -export const getNodeNumber = (queueName) => { - if (oneNodeQueueTypeList.includes(queueName)) { +export const getNodeNumber = (queueName: string) => { + if (oneNodeQueueTypeList.includes(queueName as (typeof oneNodeQueueTypeList)[number])) { return 1; } - if (maxTenNodesQueueTypeList.includes(queueName)) { + if (maxTenNodesQueueTypeList.includes(queueName as (typeof maxTenNodesQueueTypeList)[number])) { return 10; } + + return undefined; }; const timeLimitRegex = /^([0-9][0-9])?:?[0-9]?[0-9][0-9]:[0-5][0-9]:[0-5][0-9]$/; -const validateTimeLimit = (timeLimit) => Boolean(timeLimit.match(timeLimitRegex)); +const validateTimeLimit = (timeLimit: string) => Boolean(timeLimit.match(timeLimitRegex)); /** * @summary Helper to merge compute schema with application's advanced compute schema @@ -118,10 +122,10 @@ const validateTimeLimit = (timeLimit) => Boolean(timeLimit.match(timeLimitRegex) * @param appName {String} name of application with advanced compute options * @returns {*} updated schema */ -const updateComputeSchemaWithApplication = (schema, appName) => { +const updateComputeSchemaWithApplication = (schema: Record, appName: string) => { // Guard: if schema has no properties (e.g. standalone mode), return as-is. if (!schema?.properties) return schema; - const schemaIds = { + const schemaIds: Record = { espresso: "software-directory/modeling/espresso/arguments", }; const schemaId = schemaIds[appName]; @@ -145,8 +149,8 @@ const updateComputeSchemaWithApplication = (schema, appName) => { * @param appName {String} application name with advanced compute options * @returns {*} the schema */ -const getComputeSchema = (appName) => { - let schema = resolveJsonSchema("job/compute"); +const getComputeSchema = (appName: string) => { + let schema = resolveJsonSchema("job/compute") as Record; schema = updateComputeSchemaWithApplication(schema, appName); // Guard: schema may be empty ({}) in standalone mode when ESSE registry lacks 'job/compute' if (schema?.properties?.queue) { @@ -165,28 +169,39 @@ const getComputeSchema = (appName) => { * @param schema {Object} the full schema (including advanced compute options if available) * @returns {{validator: ajv.ValidateFunction, getErrorMessage: ((function(*): ({name: *, message: string}))|*)}} */ -const getComputeValidator = (schema) => { - const errorMessages = { +const getComputeValidator = (schema: Record) => { + const errorMessages: Record = { timeLimit: "Time, 00:00:00 - 99:59:59", ppn: "Max count exceeded", nodes: "Max node count for selected queue exceeded", }; const ajv = new Ajv({ allErrors: true, verbose: true }); - ajv.addKeyword("validateTimeLimit", { + ajv.addKeyword({ + keyword: "validateTimeLimit", type: "string", validate: validateTimeLimit, schema: false, }); - ajv.addKeyword("validatePpn", { type: "integer", validate: validatePpn, schema: false }); - ajv.addKeyword("validateNodes", { type: "integer", validate: validateNodes, schema: false }); + ajv.addKeyword({ + keyword: "validatePpn", + type: "integer", + validate: validatePpn, + schema: false, + }); + ajv.addKeyword({ + keyword: "validateNodes", + type: "integer", + validate: validateNodes, + schema: false, + }); /** * @summary Traverses the returned ajv object to determine which error message to display * @param obj {Object} returned object from ajv on validation failure * @returns {{name: string, message: string}} */ - const getErrorMessage = (obj) => { + const getErrorMessage = (obj: Record) => { const name = obj.instancePath.slice(1); const view = name.split(".").pop(); const message = `${s.titleize(view)} ${obj.message}.`; @@ -202,4 +217,4 @@ const getComputeValidator = (schema) => { return { validator: ajv.compile(schema), getErrorMessage }; }; -export { getComputeSchema, getComputeValidator, getNodeByHostname, defaultCluster }; +export { defaultCluster, getComputeSchema, getComputeValidator, getNodeByHostname }; diff --git a/tests/ComputableEntityMixin.tests.ts b/tests/ComputableEntityMixin.tests.ts new file mode 100644 index 0000000..c69e2c0 --- /dev/null +++ b/tests/ComputableEntityMixin.tests.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import assert from "node:assert"; +import test from "node:test"; + +import React from "react"; + +import { ComputableEntityMixin } from "../src/components/mixins"; + +/** + * Regression test for a crash caught live in production: `renderWarnings()` assumed + * every `computedEntity` implements `.warnings`, but nothing in the real stack + * (`@mat3ra/ide`'s `infrastructureMixin`, jode's `Job`, web-app's `CoreJob`) provides it - + * `ide` dropped its `warnings` fallback, and the web-app file that was meant to supply a + * real replacement was deleted entirely in an unrelated migration. Calling + * `.filter(...)` directly on `undefined` threw "Cannot read properties of undefined + * (reading 'filter')" as soon as any job rendered. + */ +class TestComponent extends ComputableEntityMixin(React.Component) { + get computedEntity() { + return { errors: [] }; + } +} + +test("renderWarnings does not throw when computedEntity has no .warnings", () => { + const instance = new TestComponent({}); + assert.doesNotThrow(() => instance.renderWarnings()); +}); + +test("renderErrors does not throw and renders provided errors", () => { + class WithErrors extends ComputableEntityMixin(React.Component) { + get computedEntity() { + return { errors: [{ message: "boom" }] }; + } + } + const instance = new WithErrors({}); + let result: React.ReactNode; + assert.doesNotThrow(() => { + result = instance.renderErrors(); + }); + assert.ok(Array.isArray(result)); + assert.strictEqual((result as unknown[]).length, 1); +}); + +test("renderWarnings renders provided warnings", () => { + class WithWarnings extends ComputableEntityMixin(React.Component) { + get computedEntity() { + return { + errors: [], + warnings: [{ condition: true, message: "heads up" }], + }; + } + } + const instance = new WithWarnings({}); + const result = instance.renderWarnings() as unknown[]; + assert.strictEqual(result.length, 1); +});