From 83784912889ee48861ab9a3d0cc53e0439cff1f4 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Fri, 28 Aug 2026 18:51:07 +0300 Subject: [PATCH 1/6] fix(SOF-8032): default errors/warnings to [] in ComputableEntityMixin [release] renderWarnings()/renderErrors() assumed every computedEntity implements .warnings/.errors and crashed with "Cannot read properties of undefined (reading 'filter')" otherwise. This started happening for every job after web-app's ide dependency dropped its warnings fallback (commit 17e163b), on the assumption web-app's own imports/jobs/job.ts would supply a real override - that file has since been deleted entirely as part of the Job DAO/use-case migration, so nothing provides .warnings at all anymore, and jode's Job/web-app's CoreJob never implemented .errors/.warnings either. Reproducible in job-designer's own standalone demo too (a bare jode Job has neither field), so this isn't web-app-specific wiring - the mixin itself needs to tolerate an optional field being absent. --- src/components/mixins.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/mixins.jsx b/src/components/mixins.jsx index 33b013d..a59dc57 100644 --- a/src/components/mixins.jsx +++ b/src/components/mixins.jsx @@ -56,7 +56,10 @@ export const ComputableEntityMixin = (superclass) => // errors come from backend renderErrors() { - const notDismissedErrors = this.computedEntity.errors.filter( + // Not every computedEntity implements errors/warnings (e.g. job-designer's + // standalone demo builds a bare jode Job with neither) - default to none rather + // than crashing the whole component on a missing optional field. + const notDismissedErrors = (this.computedEntity.errors ?? []).filter( (e, idx) => !this.state.dismissErrorAlerts[idx], ); return notDismissedErrors.length > 0 @@ -82,7 +85,7 @@ export const ComputableEntityMixin = (superclass) => // warnings are calculated "on-the-fly" renderWarnings() { - const notDismissedWarnings = this.computedEntity.warnings.filter( + const notDismissedWarnings = (this.computedEntity.warnings ?? []).filter( (e, idx) => !this.state.dismissWarningAlerts[idx], ); return notDismissedWarnings.map((warningConfig, idx) => { From 6e7c5cbe65e53350b65d0cdfbea0276c8a729e38 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Fri, 28 Aug 2026 19:17:56 +0300 Subject: [PATCH 2/6] refactor(SOF-8032): rewrite ive to TypeScript, aligned with ide's current types [release] Converts the remaining .js/.jsx files (Compute, ComputeHandler, Notify, StatusTrackTable, mixins, validators) to real TypeScript, typed against @mat3ra/ide's current ComputedEntityMixin/InfrastructureMixin contracts rather than the loose, never-type-checked shape they had before (allowJs with no checkJs meant these files were transpiled but never validated). ComputableEntity now models errors as required (every real producer supplies it) and warnings as optional - making the actual current reality (no producer of .warnings exists anywhere in jode/CoreJob since ide dropped its fallback) part of the type contract, instead of a silent runtime assumption that crashed in production. Typing surfaced three more small, real bugs, fixed in place since each is confined to the file being converted: - Compute.jsx read job.workflow.usedApplicationNames[0] - workflow is the raw JSON schema field, not the live WodeWorkflow instance. jode's Job already exposes this directly as job.usedApplicationNames. - Notify.jsx read user.email in one branch instead of user.entity.email like every other identical call site in the file. - QueuesTable's Queue.capacity was typed as required, but esse's compute/queue schema never lists it in `required` - real Queue instances can have it undefined (this was also the root cause of a pre-existing type error in web-app's ClustersPage.tsx). Widened clusters_load.ts's queueStatus/getStatus to match; its `default` switch branch already degrades gracefully for an unmatched load/capacity pair. Also dropped two dead props (`adjustable`, `isDescriptionEditorHidden`) passed to cove's - neither exists in its real prop list, confirmed by reading its actual destructured signature. job-designer's Job.jsx (the sole consumer of ComputableEntityMixin) stays untyped JS - compile-time enforcement applies within ive itself here, not yet at the actual mixing call site. --- package-lock.json | 11 +++ package.json | 1 + src/components/{Compute.jsx => Compute.tsx} | 87 +++++++++++-------- src/components/ComputeForm.tsx | 6 +- src/components/ComputeHandler.js | 24 ----- src/components/ComputeHandler.ts | 40 +++++++++ src/components/{Notify.jsx => Notify.tsx} | 65 ++++++++------ src/components/QueuesTable.tsx | 4 +- ...tusTrackTable.jsx => StatusTrackTable.tsx} | 32 ++++--- src/components/{mixins.jsx => mixins.tsx} | 59 +++++++++---- src/modules.d.ts | 3 + src/utils/clusters_load.ts | 4 +- src/{validators.js => validators.ts} | 69 +++++++++------ 13 files changed, 253 insertions(+), 152 deletions(-) rename src/components/{Compute.jsx => Compute.tsx} (69%) delete mode 100644 src/components/ComputeHandler.js create mode 100644 src/components/ComputeHandler.ts rename src/components/{Notify.jsx => Notify.tsx} (88%) rename src/components/{StatusTrackTable.jsx => StatusTrackTable.tsx} (82%) rename src/components/{mixins.jsx => mixins.tsx} (64%) rename src/{validators.js => validators.ts} (70%) 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/ComputeHandler.ts b/src/components/ComputeHandler.ts new file mode 100644 index 0000000..00b06f5 --- /dev/null +++ b/src/components/ComputeHandler.ts @@ -0,0 +1,40 @@ +import { StatefulEntityMixin } from "@mat3ra/cove/dist/mixins/statefulEntityMixin"; +import React from "react"; + +/** Minimal shape `ComputeHandlerForStatefulEntityMixin` needs off `state.entity`. */ +export interface ComputeHandlerEntity { + setCompute(compute: unknown): void; + unsetCompute(): void; +} + +type Constructor = new (...args: any[]) => T; + +export const ComputeHandlerForStatefulEntityMixin = >( + superclass: TBase, +) => + class extends StatefulEntityMixin(superclass) { + constructor(props: any) { + super(props); + this.onComputeUpdate = this.onComputeUpdate.bind(this); + this.onComputeToggle = this.onComputeToggle.bind(this); + } + + onComputeUpdate(compute: unknown) { + const { entity } = this.state; + (entity as ComputeHandlerEntity).setCompute(compute); + // cove's own .d.ts declares a required (mistyped `never`) second `callback` param + // that the real implementation treats as optional - see StatefulEntityMixin.js. + this._resetStateEntityAndUpdateParents(entity, undefined as never); + } + + onComputeToggle(checked: boolean) { + const { entity } = this.state; + if (checked) { + const staticThis = this.constructor as unknown as { getDefaultComputeConfig(): unknown }; + (entity as ComputeHandlerEntity).setCompute(staticThis.getDefaultComputeConfig()); + } else { + (entity as ComputeHandlerEntity).unsetCompute(); + } + this._resetStateEntityAndUpdateParents(entity, undefined as never); + } + }; 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 a59dc57..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,16 +80,13 @@ export const ComputableEntityMixin = (superclass) => } // override upon mixing - get computedEntity() { + get computedEntity(): ComputableEntity { throw new Error("Not implemented."); } // errors come from backend - renderErrors() { - // Not every computedEntity implements errors/warnings (e.g. job-designer's - // standalone demo builds a bare jode Job with neither) - default to none rather - // than crashing the whole component on a missing optional field. - const notDismissedErrors = (this.computedEntity.errors ?? []).filter( + renderErrors(): React.ReactNode { + const notDismissedErrors = this.computedEntity.errors.filter( (e, idx) => !this.state.dismissErrorAlerts[idx], ); return notDismissedErrors.length > 0 @@ -83,8 +110,8 @@ export const ComputableEntityMixin = (superclass) => : null; } - // warnings are calculated "on-the-fly" - renderWarnings() { + // 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], ); 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 }; From 410b707eab1e78f0a9fdf9816f48896e0237f712 Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Fri, 28 Aug 2026 19:23:39 +0300 Subject: [PATCH 3/6] fix(SOF-8032): also widen Queue.displayName to optional [release] Same issue as capacity in the prior commit: esse's compute/queue schema never lists displayName in required either, so real Queue instances can have it undefined too - this was the actual remaining cause of the ClustersPage.tsx type mismatch (capacity alone wasn't the full story). --- src/components/QueuesTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/QueuesTable.tsx b/src/components/QueuesTable.tsx index 1340a0a..0b6abe4 100644 --- a/src/components/QueuesTable.tsx +++ b/src/components/QueuesTable.tsx @@ -13,10 +13,10 @@ import { ClustersLoadHandler } from "../utils/clusters_load"; /** Minimal interface for queue objects passed from the host application. */ export interface Queue { name: string; - displayName: string; + // esse's `compute/queue` schema never lists `displayName`/`capacity` in `required` - + // real Queue instances can genuinely have either undefined. + displayName?: string; maxAvailableNodect: number; - // esse's `compute/queue` schema never lists `capacity` in `required` - real Queue - // instances can genuinely have it undefined. capacity?: string; load: number; getETAClient: () => { display: string }; From 2a8a93e587935771a4c62496d9daf7ea1a5fa1cc Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Fri, 28 Aug 2026 22:22:09 +0300 Subject: [PATCH 4/6] chore(SOF-8032): remove dead ComputeHandlerForStatefulEntityMixin [release] Not exported from exports.ts, not referenced anywhere else in ive's own src/, and not imported via a deep path from job-designer, workflow-designer, materials-designer, or web-app - confirmed unreachable and unused. --- src/components/ComputeHandler.ts | 40 -------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 src/components/ComputeHandler.ts diff --git a/src/components/ComputeHandler.ts b/src/components/ComputeHandler.ts deleted file mode 100644 index 00b06f5..0000000 --- a/src/components/ComputeHandler.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { StatefulEntityMixin } from "@mat3ra/cove/dist/mixins/statefulEntityMixin"; -import React from "react"; - -/** Minimal shape `ComputeHandlerForStatefulEntityMixin` needs off `state.entity`. */ -export interface ComputeHandlerEntity { - setCompute(compute: unknown): void; - unsetCompute(): void; -} - -type Constructor = new (...args: any[]) => T; - -export const ComputeHandlerForStatefulEntityMixin = >( - superclass: TBase, -) => - class extends StatefulEntityMixin(superclass) { - constructor(props: any) { - super(props); - this.onComputeUpdate = this.onComputeUpdate.bind(this); - this.onComputeToggle = this.onComputeToggle.bind(this); - } - - onComputeUpdate(compute: unknown) { - const { entity } = this.state; - (entity as ComputeHandlerEntity).setCompute(compute); - // cove's own .d.ts declares a required (mistyped `never`) second `callback` param - // that the real implementation treats as optional - see StatefulEntityMixin.js. - this._resetStateEntityAndUpdateParents(entity, undefined as never); - } - - onComputeToggle(checked: boolean) { - const { entity } = this.state; - if (checked) { - const staticThis = this.constructor as unknown as { getDefaultComputeConfig(): unknown }; - (entity as ComputeHandlerEntity).setCompute(staticThis.getDefaultComputeConfig()); - } else { - (entity as ComputeHandlerEntity).unsetCompute(); - } - this._resetStateEntityAndUpdateParents(entity, undefined as never); - } - }; From 70f935f7a9cf17120d94e455f3f4cdf46a9a5d1f Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Fri, 28 Aug 2026 22:32:49 +0300 Subject: [PATCH 5/6] test(SOF-8032): regression test for ComputableEntityMixin's renderWarnings crash Mixes ComputableEntityMixin into a plain React.Component and calls renderWarnings()/renderErrors() directly with a computedEntity that has no .warnings field, reproducing the exact production crash ("Cannot read properties of undefined (reading 'filter')") without needing a DOM or the full job-designer/web-app rendering stack. Verified genuine: temporarily reverted the `?? []` defensive default back to a bare `this.computedEntity.warnings!` and confirmed the test fails at the exact throwing line with the exact reported error, then restored the fix and confirmed all tests pass again. --- tests/ComputableEntityMixin.tests.ts | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/ComputableEntityMixin.tests.ts 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); +}); From 19ac693f26c1116335bf8d283e82eab0930d8b6a Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Mon, 31 Aug 2026 13:54:23 +0300 Subject: [PATCH 6/6] chore(SOF-8032): re-cut WIP release, previous tag expired [release] wip-2a8a93e (job-designer's pin) was deleted by ive's own scheduled cleanup-wip-releases.yml (runs every Monday, deletes stale WIP tarballs by design - WIP releases are meant as short-lived scaffolding for cross-package testing, not a long-term pin). That broke job-designer's Netlify Deploy Preview: `npm error 404 ... releases/download/wip-2a8a93e/ive.tgz`. No code change - just re-triggering release-wip.yml for the current HEAD so job-designer has a live tarball to repin to. The durable fix is merging PR #8 to main for a real, non-expiring CalVer publish.