diff --git a/pkg/lib/cockpit.d.ts b/pkg/lib/cockpit.d.ts index 99524c268755..22d4af66e033 100644 --- a/pkg/lib/cockpit.d.ts +++ b/pkg/lib/cockpit.d.ts @@ -220,6 +220,7 @@ declare module 'cockpit' { interface DBusOptions { bus?: string; address?: string; + host?: string; superuser?: "require" | "try"; track?: boolean; } diff --git a/pkg/shell/machines/machines.d.ts b/pkg/shell/machines/machines.d.ts index 7a0d9f7fa76a..3165f88d9890 100644 --- a/pkg/shell/machines/machines.d.ts +++ b/pkg/shell/machines/machines.d.ts @@ -1,4 +1,4 @@ -import { EventSource, EventMap } from "cockpit"; +import { EventSource, EventMap, JsonObject } from "cockpit"; export function generate_connection_string(user: string | null, port: string | null, addr: string) : string; export function split_connection_string (conn_to: string) : { address: string, user?: string, port?: number }; @@ -27,9 +27,7 @@ export interface ManifestSection { [name: string]: ManifestEntry; } -export interface Manifest { - [section: string]: ManifestSection; -} +export type Manifest = JsonObject; export interface Manifests { [pkg: string]: Manifest; diff --git a/pkg/shell/nav.jsx b/pkg/shell/nav.tsx similarity index 69% rename from pkg/shell/nav.jsx rename to pkg/shell/nav.tsx index c78e7c3b53d4..c3ee48ccb5d3 100644 --- a/pkg/shell/nav.jsx +++ b/pkg/shell/nav.tsx @@ -1,7 +1,25 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2024 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + import cockpit from "cockpit"; import React, { useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { Nav } from "@patternfly/react-core/dist/esm/components/Nav/index.js"; @@ -9,7 +27,9 @@ import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchIn import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js"; import { ContainerNodeIcon, ExclamationCircleIcon, ExclamationTriangleIcon, InfoCircleIcon } from '@patternfly/react-icons'; -import { encode_location } from "./util.jsx"; +import { Location, encode_location, ManifestItem } from "./util.jsx"; +import { ShellState, PageStatus } from "./state"; +import { ManifestKeyword } from "./machines/machines"; const _ = cockpit.gettext; @@ -22,8 +42,8 @@ export const SidebarToggle = () => { * However, when clicking on an iframe moves focus to its content's window that triggers the main window.blur event. * Additionally, when clicking on an element in the same iframe make sure to unset the 'active' state of the 'System' dropdown selector. */ - const handleClickOutside = (ev) => { - if (ev.target.id == "nav-system-item") + const handleClickOutside = (ev: Event) => { + if ((ev.target as Element).id == "nav-system-item") return; setActive(false); @@ -37,7 +57,7 @@ export const SidebarToggle = () => { }, []); useEffect(() => { - document.getElementById("nav-system").classList.toggle("interact", active); + document.getElementById("nav-system")!.classList.toggle("interact", active); }, [active]); return ( @@ -50,8 +70,45 @@ export const SidebarToggle = () => { ); }; +interface NavKeyword { + keyword: string; + score: number; + goto: string | null; +} + +interface NavItem extends ManifestItem { + keyword: NavKeyword; +} + +interface ItemGroup { + name: string; + items: T[]; + action?: { + label: string; + target: Location; + } | undefined; +} + +interface CockpitNavProps { + groups: ItemGroup[]; + selector: string; + current: string; + filtering: (item: ManifestItem, term: string) => NavItem | null; + sorting: (a: NavItem, b: NavItem) => number; + item_render: (item: NavItem, term: string) => React.ReactNode; + jump: (loc: Location) => void; +} + +interface CockpitNavState { + search: string; + current: string; +} + export class CockpitNav extends React.Component { - constructor(props) { + props: CockpitNavProps; + state: CockpitNavState; + + constructor(props : CockpitNavProps) { super(props); this.state = { @@ -60,29 +117,29 @@ export class CockpitNav extends React.Component { }; this.clearSearch = this.clearSearch.bind(this); + this.props = props; } componentDidMount() { - const self = this; const sel = this.props.selector; // Click on active menu item (when using arrows to navigate through menu) function clickActiveItem() { const cur = document.activeElement; - if (cur.nodeName === "INPUT") { - const el = document.querySelector("#" + sel + " li:first-of-type a"); + if (cur instanceof HTMLInputElement) { + const el: HTMLElement | null = document.querySelector("#" + sel + " li:first-of-type a"); if (el) el.click(); - } else { + } else if (cur instanceof HTMLElement) { cur.click(); } } // Move focus to next item in menu (when using arrows to navigate through menu) // With arguments it is possible to change direction - function focusNextItem(begin, step) { + function focusNextItem(begin: number, step: number) { const cur = document.activeElement; - const all = Array.from(document.querySelectorAll("#" + sel + " li a")); - if (cur.nodeName === "INPUT" && all) { + const all = Array.from(document.querySelectorAll("#" + sel + " li a")); + if (cur instanceof HTMLInputElement && all.length > 0) { if (begin < 0) begin = all.length - 1; all[begin].focus(); @@ -90,30 +147,29 @@ export class CockpitNav extends React.Component { let i = all.findIndex(item => item === cur); i += step; if (i < 0 || i >= all.length) - document.querySelector("#" + sel + " .pf-v5-c-text-input-group__text-input").focus(); + document.querySelector("#" + sel + " .pf-v5-c-text-input-group__text-input")?.focus(); else all[i].focus(); } } - function navigate_apps(ev) { - if (ev.keyCode === 13) // Enter + const navigate_apps = (ev: KeyboardEvent) => { + if (ev.key == "Enter") clickActiveItem(); - else if (ev.keyCode === 40) // Arrow Down + else if (ev.key == "ArrowDown") focusNextItem(0, 1); - else if (ev.keyCode === 38) // Arrow Up + else if (ev.key == "ArrowUp") focusNextItem(-1, -1); - else if (ev.keyCode === 27) { // Escape - clean selection - self.setState({ search: "" }); - document.querySelector("#" + sel + " .pf-v5-c-text-input-group__text-input").focus(); + else if (ev.key == "Escape") { + this.setState({ search: "" }); + document.querySelector("#" + sel + " .pf-v5-c-text-input-group__text-input")?.focus(); } - } + }; - document.getElementById(sel).addEventListener("keyup", navigate_apps); - document.getElementById(sel).addEventListener("change", navigate_apps); + document.getElementById(sel)?.addEventListener("keyup", navigate_apps); } - static getDerivedStateFromProps(nextProps, prevState) { + static getDerivedStateFromProps(nextProps: CockpitNavProps, prevState: CockpitNavState) { if (nextProps.current !== prevState.current) return { search: "", @@ -127,10 +183,10 @@ export class CockpitNav extends React.Component { } render() { - const groups = []; + const groups: ItemGroup[] = []; const term = this.state.search.toLowerCase(); this.props.groups.forEach(g => { - const new_items = g.items.map(i => this.props.filtering(i, term)).filter(Boolean); + const new_items = g.items.map(i => this.props.filtering(i, term)).filter((i): i is NavItem => !!i); new_items.sort(this.props.sorting); if (new_items.length > 0) groups.push({ name: g.name, items: new_items, action: g.action }); @@ -139,7 +195,7 @@ export class CockpitNav extends React.Component { return ( <> this.setState({ search })} onClear={() => this.setState({ search: "" })} className="search" /> - + { groups.map(g => @@ -148,7 +204,8 @@ export class CockpitNav extends React.Component { { - this.props.jump(g.action.target); + if (g.action) + this.props.jump(g.action.target); ev.preventDefault(); }}> {g.action.label} @@ -168,20 +225,11 @@ export class CockpitNav extends React.Component { } } -CockpitNav.propTypes = { - groups: PropTypes.array.isRequired, - selector: PropTypes.string.isRequired, - item_render: PropTypes.func.isRequired, - filtering: PropTypes.func.isRequired, - sorting: PropTypes.func.isRequired, - jump: PropTypes.func.isRequired, -}; - -function PageStatus({ status, name }) { +function PageStatus({ status, name } : { status: PageStatus, name: string }) { // Generate name for the status - let desc = name.toLowerCase().split(" "); - desc.push(status.type); - desc = desc.join("-"); + const desc_parts = name.toLowerCase().split(" "); + desc_parts.push(status.type); + const desc = desc_parts.join("-"); return ( void; + actions?: React.ReactNode; +}) { const s = props.status; const name_matches = props.keyword === props.name.toLowerCase(); let header_matches = false; @@ -243,19 +302,7 @@ export function CockpitNavItem(props) { ); } -CockpitNavItem.propTypes = { - name: PropTypes.string.isRequired, - href: PropTypes.string.isRequired, - onClick: PropTypes.func, - status: PropTypes.object, - active: PropTypes.bool, - keyword: PropTypes.string, - term: PropTypes.string, - header: PropTypes.string, - actions: PropTypes.node, -}; - -export const PageNav = ({ state }) => { +export const PageNav = ({ state } : { state: ShellState }) => { const { current_machine, current_manifest_item, @@ -263,18 +310,18 @@ export const PageNav = ({ state }) => { page_status, } = state; - if (!current_machine || current_machine.state != "connected") { + if (!current_machine || current_machine.state != "connected" || !current_machine_manifest_items || !current_manifest_item) { return null; } // Filtering of navigation by term - function keyword_filter(item, term) { - function keyword_relevance(current_best, item) { + function keyword_filter(item: ManifestItem, term: string): NavItem | null { + function keyword_relevance(current_best: NavKeyword, item: ManifestKeyword) { const translate = item.translate || false; const weight = item.weight || 0; let score; let _m = ""; - let best = { score: -1 }; + let best: NavKeyword = { keyword: "", score: -1, goto: null }; item.matches.forEach(m => { if (translate) _m = _(m); @@ -293,20 +340,19 @@ export const PageNav = ({ state }) => { score = 1 + weight; } if (score > best.score) { - best = { keyword: m, score }; + best = { keyword: m, score, goto: item.goto || null }; } }); if (best.score > current_best.score) { - current_best = { keyword: best.keyword, score: best.score, goto: item.goto || null }; + current_best = best; } return current_best; } - const new_item = Object.assign({}, item); - new_item.keyword = { score: -1 }; + const new_item: NavItem = Object.assign({ keyword: { keyword: "", score: -1, goto: null } }, item); if (!term) return new_item; - const best_keyword = new_item.keywords.reduce(keyword_relevance, { score: -1 }); + const best_keyword = new_item.keywords.reduce(keyword_relevance, { keyword: "", score: -1, goto: null }); if (best_keyword.score > -1) { new_item.keyword = best_keyword; return new_item; @@ -315,8 +361,8 @@ export const PageNav = ({ state }) => { } // Rendering of separate navigation menu items - function nav_item(item, term) { - const active = current_manifest_item.path === item.path; + function nav_item(item: NavItem, term: string) { + const active = current_manifest_item?.path === item.path; // Parse path let path = item.path; @@ -330,10 +376,10 @@ export const PageNav = ({ state }) => { // Parse page status let status = null; - if (page_status[current_machine.key]) - status = page_status[current_machine.key][item.path]; + if (page_status[current_machine!.key]) + status = page_status[current_machine!.key][item.path]; - const target_location = { host: current_machine.address, path, hash }; + const target_location = { host: current_machine!.address, path, hash }; return ( { ); } - const groups = [ + const groups: ItemGroup[] = [ { name: _("Apps"), items: current_machine_manifest_items.ordered("dashboard"), @@ -366,6 +412,7 @@ export const PageNav = ({ state }) => { target: { host: current_machine.address, path: current_machine_manifest_items.items.apps.path, + hash: "/", } }; diff --git a/pkg/shell/shell-modals.jsx b/pkg/shell/shell-modals.tsx similarity index 93% rename from pkg/shell/shell-modals.jsx rename to pkg/shell/shell-modals.tsx index ea9b4b67e44d..fc8014e90215 100644 --- a/pkg/shell/shell-modals.jsx +++ b/pkg/shell/shell-modals.tsx @@ -17,6 +17,8 @@ * along with Cockpit; If not, see . */ +// @cockpit-ts-relaxed + import cockpit from "cockpit"; import React, { useState } from "react"; import { AboutModal } from "@patternfly/react-core/dist/esm/components/AboutModal/index.js"; @@ -31,17 +33,19 @@ import { SearchIcon } from '@patternfly/react-icons'; import { useInit } from "hooks"; +import { ShellManifest } from "./util"; + import "menu-select-widget.scss"; const _ = cockpit.gettext; export const AboutCockpitModal = ({ dialogResult }) => { - const [packages, setPackages] = useState(null); + const [packages, setPackages] = useState<{ name: string, version: string }[]>([]); useInit(() => { const packages = []; const cmd = "(set +e; rpm -qa --qf '%{NAME} %{VERSION}\\n'; dpkg-query -f '${Package} ${Version}\n' --show; pacman -Q) 2> /dev/null | grep cockpit | sort"; - cockpit.spawn(["bash", "-c", cmd], [], { err: "message" }) + cockpit.spawn(["bash", "-c", cmd], { err: "message" }) .then(pkgs => pkgs.trim().split("\n") .forEach(p => { @@ -99,10 +103,10 @@ export const LangModal = ({ dialogResult }) => { const cookie = "CockpitLang=" + encodeURIComponent(selected) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT"; document.cookie = cookie; window.localStorage.setItem("cockpit.lang", selected); - window.location.reload(true); + window.location.reload(); } - const manifest = cockpit.manifests.shell || { }; + const manifest = (cockpit.manifests.shell || { }) as ShellManifest; return ( { isPlain isScrollable className="ct-menu-select-widget" - onSelect={(_, selected) => setSelected(selected)} + onSelect={(_, selected) => setSelected(selected as string)} activeItemId={selected} selected={selected}> @@ -140,8 +144,9 @@ export const LangModal = ({ dialogResult }) => { { (() => { - const filteredLocales = Object.keys(manifest.locales || {}) - .filter(key => !searchInput || manifest.locales[key].toLowerCase().includes(searchInput.toString().toLowerCase())); + const locales = manifest.locales || {}; + const filteredLocales = Object.keys(locales) + .filter(key => !searchInput || locales[key].toLowerCase().includes(searchInput.toString().toLowerCase())); if (filteredLocales.length === 0) { return ( @@ -153,7 +158,7 @@ export const LangModal = ({ dialogResult }) => { return filteredLocales.map(key => { return ( - {manifest.locales[key]} + {locales[key]} ); }); diff --git a/pkg/shell/state.tsx b/pkg/shell/state.tsx index c6c2c23103a4..df51912bbec2 100644 --- a/pkg/shell/state.tsx +++ b/pkg/shell/state.tsx @@ -54,6 +54,14 @@ export interface ShellStateEvents { connect: () => void; } +// NOTE - this is defined in pkg/lib/notifications.js and should be +// imported from there once that file has been typed. +// +export interface PageStatus { + type: string; + title: string; +} + export class ShellState extends EventEmitter { constructor() { super(); @@ -307,13 +315,13 @@ export class ShellState extends EventEmitter { * individual pages have access to all collected statuses. */ - page_status: { [host: string]: { [page: string]: unknown } } = { }; + page_status: { [host: string]: { [page: string]: PageStatus } } = { }; #init_page_status() { sessionStorage.removeItem("cockpit:page_status"); } - #notify_page_status(host: string, page: string, status: unknown) { + #notify_page_status(host: string, page: string, status: PageStatus) { if (!this.page_status[host]) this.page_status[host] = { }; this.page_status[host][page] = status; @@ -391,7 +399,7 @@ export class ShellState extends EventEmitter { * "well-known name" of a page, such as "system", * "network/firewall", or "updates". */ - handle_notifications: (host: string, page: string, data: { page_status?: unknown }) => { + handle_notifications: (host: string, page: string, data: { page_status?: PageStatus }) => { if (data.page_status !== undefined) this.#notify_page_status(host, page, data.page_status); }, diff --git a/pkg/shell/topnav.jsx b/pkg/shell/topnav.tsx similarity index 80% rename from pkg/shell/topnav.jsx rename to pkg/shell/topnav.tsx index 81bf88c0523d..6e6ba38e6154 100644 --- a/pkg/shell/topnav.jsx +++ b/pkg/shell/topnav.tsx @@ -29,6 +29,9 @@ import { ToggleGroup, ToggleGroupItem } from "@patternfly/react-core/dist/esm/co import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js"; import { CogIcon, ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons'; +import { ShellState } from "./state"; +import { ManifestDocs } from "./machines/machines"; +import { ManifestParentSection, ShellManifest } from "./util"; import { ActivePagesDialog } from "./active-pages-modal.jsx"; import { CredentialsModal } from './credentials.jsx'; import { AboutCockpitModal, LangModal, OopsModal } from "./shell-modals.jsx"; @@ -38,11 +41,28 @@ import { DialogsContext } from "dialogs.jsx"; const _ = cockpit.gettext; +interface TopNavProps { + state: ShellState; +} + +interface TopNavState { + docsOpened: boolean; + menuOpened: boolean; + showActivePages: boolean; + osRelease: cockpit.JsonObject; + theme: string; +} + export class TopNav extends React.Component { static contextType = DialogsContext; + declare context: React.ContextType; + + props: TopNavProps; + state: TopNavState; - constructor(props) { + constructor(props: TopNavProps) { super(props); + this.props = props; this.state = { docsOpened: false, @@ -52,14 +72,14 @@ export class TopNav extends React.Component { theme: localStorage.getItem('shell:style') || 'auto', }; - this.superuser_connection = null; - this.superuser = null; - read_os_release().then(os => this.setState({ osRelease: os || {} })); - - this.handleClickOutside = () => this.setState({ menuOpened: false, docsOpened: false }); } + superuser_connection: cockpit.DBusClient | null = null; + superuser: cockpit.DBusProxy | null = null; + + handleClickOutside = () => this.setState({ menuOpened: false, docsOpened: false }); + componentDidMount() { /* This is a HACK for catching lost clicks on the pages which live in iframes so as to close dropdown menus on the shell. * Note: Clicks on an element won't trigger document.documentElement listeners, because it's literally different page with different security domain. @@ -72,8 +92,7 @@ export class TopNav extends React.Component { window.removeEventListener("blur", this.handleClickOutside); } - handleModeClick = (event, isSelected) => { - const theme = event.currentTarget.id; + handleModeClick = (theme: string) => { this.setState({ theme }); const styleEvent = new CustomEvent("cockpit-style", { @@ -88,7 +107,7 @@ export class TopNav extends React.Component { }; render() { - const Dialogs = this.context; + const Dialogs = this.context!; const { current_machine, current_manifest_item, @@ -96,9 +115,11 @@ export class TopNav extends React.Component { current_frame, } = this.props.state; + cockpit.assert(current_machine && current_manifest && current_manifest_item); + const connected = current_machine.state === "connected"; - let docs = []; + let docs: ManifestDocs[] = []; if (!this.superuser_connection || (this.superuser_connection.options.host != current_machine.connection_string)) { @@ -115,20 +136,23 @@ export class TopNav extends React.Component { // Check first whether we have docs in the "parent" section of // the manifest. - if (current_manifest.parent && current_manifest.parent.docs) - docs = current_manifest.parent.docs; + const parent = (current_manifest.parent || {}) as ManifestParentSection; + if (parent.docs) + docs = parent.docs; else if (current_manifest_item.docs) docs = current_manifest_item.docs; const docItems = []; - if (this.state.osRelease.DOCUMENTATION_URL) - docItems.push(}> + if (this.state.osRelease?.DOCUMENTATION_URL) + docItems.push(}> {cockpit.format(_("$0 documentation"), this.state.osRelease.NAME)} ); + const shell_manifest = (cockpit.manifests.shell || {}) as ShellManifest; + // global documentation for cockpit as a whole - (cockpit.manifests.shell?.docs ?? []).forEach(doc => { + (shell_manifest.docs ?? []).forEach(doc => { docItems.push(}> {doc.label} ); @@ -149,8 +173,6 @@ export class TopNav extends React.Component { {_("About Web Console")} ); - const manifest = cockpit.manifests.shell || { }; - // HACK: This should be a DropdownItem so the normal onSelect closing behaviour works, but we can't embed a button in a button const main_menu = [ { - this.setState(prevState => { return { menuOpened: !prevState.menuOpened } }); + this.setState((prevState: TopNavState) => { return { menuOpened: !prevState.menuOpened } }); }}> , @@ -169,13 +191,13 @@ export class TopNav extends React.Component { + onChange={() => this.handleModeClick("auto")} /> + onChange={() => this.handleModeClick("light")} /> + onChange={() => this.handleModeClick("dark")} /> @@ -183,7 +205,7 @@ export class TopNav extends React.Component { , ]; - if (manifest.locales) + if (shell_manifest.locales) main_menu.push( Dialogs.run(LangModal, {})}> {_("Display language")} @@ -214,7 +236,7 @@ export class TopNav extends React.Component { { (current_frame && !current_frame.ready) && - + } { connected && @@ -232,8 +254,10 @@ export class TopNav extends React.Component { { - this.setState(prevState => { return { docsOpened: !prevState.docsOpened } }); - document.getElementById("toggle-docs").focus(); + this.setState((prevState: TopNavState) => { + return { docsOpened: !prevState.docsOpened }; + }); + document.getElementById("toggle-docs")?.focus(); }} toggle={(toggleRef) => ( } isExpanded={this.state.docsOpened} isFullHeight - onClick={() => { this.setState(prevState => ({ docsOpened: !prevState.docsOpened, menuOpened: false })) }}> + onClick={() => { + this.setState((prevState: TopNavState) => ({ + docsOpened: !prevState.docsOpened, + menuOpened: false + })); + }}> {_("Help")} )} @@ -259,8 +288,10 @@ export class TopNav extends React.Component { { - this.setState(prevState => { return { menuOpened: !prevState.menuOpened } }); - document.getElementById("toggle-menu").focus(); + this.setState((prevState: TopNavState) => { + return { menuOpened: !prevState.menuOpened }; + }); + document.getElementById("toggle-menu")?.focus(); }} toggle={(toggleRef) => ( { - this.setState(prevState => ({ menuOpened: !prevState.menuOpened, docsOpened: false, showActivePages: event.altKey })); + this.setState((prevState: TopNavState) => ({ + menuOpened: !prevState.menuOpened, + docsOpened: false, + showActivePages: event.altKey + })); }} > {_("Session")} diff --git a/pkg/shell/util.tsx b/pkg/shell/util.tsx index fb667490cac2..175c2ade8e06 100644 --- a/pkg/shell/util.tsx +++ b/pkg/shell/util.tsx @@ -19,7 +19,7 @@ import cockpit from "cockpit"; -import { ManifestKeyword, ManifestDocs, Manifest, Manifests, Machine } from "./machines/machines"; +import { ManifestKeyword, ManifestDocs, ManifestSection, Manifest, Manifests, Machine } from "./machines/machines"; export interface Location { host: string; @@ -27,7 +27,7 @@ export interface Location { hash: string; } -export function encode_location(location: Location): string { +export function encode_location(location: Partial): string { const shell_embedded = window.location.pathname.indexOf(".html") !== -1; if (shell_embedded) return window.location.toString(); @@ -91,6 +91,16 @@ export interface ManifestItem { keywords: ManifestKeyword[]; } +export interface ManifestParentSection { + component?: string; + docs?: ManifestDocs[]; +} + +export interface ShellManifest { + docs?: ManifestDocs[]; + locales?: { [id: string]: string }; +} + export class CompiledComponents { manifests: Manifests; items: { [path: string] : ManifestItem; } = { }; @@ -101,7 +111,8 @@ export class CompiledComponents { load(section: string): void { Object.entries(this.manifests).forEach(([name, manifest]) => { - Object.entries(manifest[section] || { }).forEach(([prop, info]) => { + const manifest_section = (manifest[section] || {}) as ManifestSection; + Object.entries(manifest_section).forEach(([prop, info]) => { const item: ManifestItem = { path: "", // set below hash: "", // set below @@ -172,8 +183,11 @@ export class CompiledComponents { // Still don't know where it comes from, check for parent if (!component) { const comp = this.manifests[path]; - if (comp && comp.parent && comp.parent.component) - component = comp.parent.component as string; + if (comp && comp.parent) { + const parent = comp.parent as ManifestParentSection; + if (parent.component) + component = parent.component; + } } const item = this.items[component];