Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions webui/src/Instances/HelpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useS
import semver from 'semver'
import type { ModuleInstanceType } from '@companion-app/shared/Model/Instance.js'
import { Modal } from '~/Components/Modal'
import { ModuleDeprecationAlert, useModuleDeprecation } from '~/Modules/ModuleDeprecation.js'
import { sanitizeHtmlString } from '~/Resources/SanitizeHtml.js'
import { makeAbsolutePath } from '~/Resources/util.js'
import { RootAppStoreContext } from '~/Stores/RootAppStore.js'
Expand All @@ -16,11 +17,20 @@ export interface HelpModalRef {
interface HelpDisplayInfo {
moduleType: ModuleInstanceType
moduleId: string
versionId: string
versionDisplayName: string
markdown: string
baseUrl: string
}

/** The deprecation notice for the module the help is being shown for, if it has one */
const HelpDeprecationAlert = observer(function HelpDeprecationAlert({ content }: { content: HelpDisplayInfo }) {
const deprecation = useModuleDeprecation(content.moduleType, content.moduleId, content.versionId)
if (!deprecation) return null

return <ModuleDeprecationAlert deprecation={deprecation} />
})

export const HelpModal = observer(
forwardRef<HelpModalRef>(function HelpModal(_props, ref) {
const { modules } = useContext(RootAppStoreContext)
Expand All @@ -47,6 +57,7 @@ export const HelpModal = observer(
setContent({
moduleType,
moduleId,
versionId,
versionDisplayName: versionDisplayName,
markdown: text,
baseUrl: fixedUrl,
Expand All @@ -57,6 +68,7 @@ export const HelpModal = observer(
setContent({
moduleType,
moduleId,
versionId,
versionDisplayName: versionDisplayName,
markdown: `Failed to load help text: ${e}`,
baseUrl: '/null',
Expand Down Expand Up @@ -101,6 +113,7 @@ export const HelpModal = observer(
</Modal.Title>
</Modal.Header>
<Modal.Body>
{content && <HelpDeprecationAlert content={content} />}
<div dangerouslySetInnerHTML={html} className="markdown" />
</Modal.Body>
</Modal.Popup>
Expand Down
24 changes: 24 additions & 0 deletions webui/src/Instances/InstanceEdit/InstanceEditPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { InstanceEditPanelService } from '~/Instances/InstanceEdit/Instance
import { InstanceEditPanelStore, isConfigFieldSecret } from '~/Instances/InstanceEdit/InstanceEditPanelStore.js'
import { InstanceSecretField } from '~/Instances/InstanceEdit/InstanceSecretField.js'
import { getModuleVersionInfo } from '~/Instances/Util.js'
import { ModuleDeprecationAlert, useModuleDeprecation } from '~/Modules/ModuleDeprecation.js'
import { LoadingRetryOrError } from '~/Resources/Loading.js'
import { RootAppStoreContext } from '~/Stores/RootAppStore.js'
import { InstanceVersionChangeButton } from '../../Instances/InstanceEdit/InstanceVersionChangeButton.js'
Expand Down Expand Up @@ -100,6 +101,8 @@ export const InstanceGenericEditPanel = observer(function InstanceGenericEditPan
</Grid.Col>
)}

<InstanceDeprecationAlert panelStore={panelStore} />

<InstanceLabelInputField panelStore={panelStore} />
<InstanceEnabledInputField panelStore={panelStore} cannotEnableReason={cannotEnableReason} />

Expand All @@ -121,6 +124,27 @@ export const InstanceGenericEditPanel = observer(function InstanceGenericEditPan
)
})

/**
* Why the module behind this instance should no longer be used, spelled out at the top of the panel.
* Renders nothing - not even its column - when the module and the version in use are both current.
*/
const InstanceDeprecationAlert = observer(function InstanceDeprecationAlert<TConfig extends ClientInstanceConfigBase>({
panelStore,
}: {
panelStore: InstanceEditPanelStore<TConfig>
}): React.JSX.Element | null {
const { moduleType, moduleId, moduleVersionId } = panelStore.instanceInfo

const deprecation = useModuleDeprecation(moduleType, moduleId, moduleVersionId)
if (!deprecation) return null

return (
<Grid.Col sm={12}>
<ModuleDeprecationAlert deprecation={deprecation} />
</Grid.Col>
)
})

function InstanceFieldLabel({ fieldInfo }: { fieldInfo: SomeCompanionInputField }) {
return (
<>
Expand Down
8 changes: 7 additions & 1 deletion webui/src/Instances/List/InstancesListTableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { InstanceStatusEntry } from '@companion-app/shared/Model/InstanceSt
import { Popover } from '~/Components/Popover'
import { SwitchInputField } from '~/Components/SwitchInputField'
import { windowLinkOpen } from '~/Helpers/Window'
import { ModuleDeprecationBadge, useModuleDeprecation } from '~/Modules/ModuleDeprecation.js'
import { MyErrorBoundary } from '~/Resources/Error'
import { isCollectionEnabled, makeAbsolutePath } from '~/Resources/util'
import type { GenericCollectionsStore } from '~/Stores/GenericCollectionsStore'
Expand Down Expand Up @@ -63,6 +64,8 @@ export const InstancesListTableRow = observer(function InstancesListTableRow<TMe

const moduleVersion = getModuleVersionInfo(moduleInfo, instance.moduleVersionId)

const deprecation = useModuleDeprecation(instance.moduleType, instance.moduleId, instance.moduleVersionId)

const doShowHelp = useCallback(
() =>
moduleVersion?.helpPath &&
Expand Down Expand Up @@ -91,7 +94,10 @@ export const InstancesListTableRow = observer(function InstancesListTableRow<TMe
>
<div onClick={doEdit} className="flex flex-col grow min-w-0">
<b>{instance.label}</b>
<span className="truncate">{moduleDisplayName}</span>
<span className="flex items-center gap-1 min-w-0">
<span className="truncate">{moduleDisplayName}</span>
{deprecation && <ModuleDeprecationBadge deprecation={deprecation} />}
</span>
</div>

<div onClick={doEdit} className="whitespace-nowrap">
Expand Down
182 changes: 182 additions & 0 deletions webui/src/Modules/ModuleDeprecation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/* eslint-disable react-refresh/only-export-components -- the lookup hook belongs beside the two components that render its result */
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useContext } from 'react'
import type { ModuleInstanceType } from '@companion-app/shared/Model/Instance.js'
import { StaticAlert } from '~/Components/Alert.js'
import { Badge } from '~/Components/Badge.js'
import { InlineHelpCustom } from '~/Components/InlineHelp.js'
import { RootAppStoreContext } from '~/Stores/RootAppStore.js'
import { useModuleStoreInfo } from './useModuleStoreInfo.js'

/**
* Why a module, or the version of it in use, should no longer be used. A `null` field means that
* level is not deprecated; an empty string means it is, but the store gave no reason.
*/
export interface ModuleDeprecationInfo {
/** The module as a whole has been deprecated */
module: string | null
/** The specific version in use has been deprecated */
version: string | null
}

/**
* Collect any deprecation notices covering a module, and the version of it in use. Returns `null`
* when nothing is deprecated, so a caller can skip its wrapper markup entirely.
*
* Must be called from an `observer` component - it reads the module stores.
*/
export function useModuleDeprecation(
moduleType: ModuleInstanceType,
moduleId: string,
/** The version in use, or `null` when no particular version is in context */
versionId: string | null
): ModuleDeprecationInfo | null {
const { modules } = useContext(RootAppStoreContext)

// The store only publishes the versions it serves, so it can never deprecate a dev or builtin one
const storeVersionId = versionId === null || versionId === 'dev' || versionId === 'builtin' ? null : versionId

// Only pay for the per-module subscription when there is a store version to look up
const moduleStoreInfo = useModuleStoreInfo(moduleType, storeVersionId === null ? undefined : moduleId)

const moduleReason = modules.getStoreInfo(moduleType, moduleId)?.deprecationReason ?? null
const versionReason =
(storeVersionId === null
? null
: moduleStoreInfo?.versions.find((version) => version.id === storeVersionId)?.deprecationReason) ?? null

if (moduleReason === null && versionReason === null) return null

return { module: moduleReason, version: versionReason }
}

/** What a notice is about. The heading already names it, so the reason itself need not repeat it. */
export type ModuleDeprecationSubject = 'module' | 'version'

export interface ModuleDeprecationNotice {
subject: ModuleDeprecationSubject
/** The store's reason, or advice to act on where the store gave nothing worth showing */
text: string
}

/** Heading when a notice stands alone, and label when it shares the block with the other subject */
export const MODULE_DEPRECATION_TITLES: Record<ModuleDeprecationSubject, string> = {
module: 'Deprecated module',
version: 'Deprecated version',
}
export const MODULE_DEPRECATION_LABELS: Record<ModuleDeprecationSubject, string> = {
module: 'This module',
version: 'The version in use',
}

/** What the store sends when it has nothing to say beyond the fact of the deprecation */
const BOILERPLATE_REASON: Record<ModuleDeprecationSubject, RegExp> = {
module: /^module is deprecated[.!]?$/i,
version: /^version is deprecated[.!]?$/i,
}

const FALLBACK_ADVICE: Record<ModuleDeprecationSubject, string> = {
module: 'It will receive no further updates. You should look for an alternative.',
version: 'You should change to a version that is still supported.',
}

/**
* The store's reason, less any leading line that only restates the deprecation - most entries open
* with its boilerplate, which the heading already says. Falls back to advice when nothing is left.
*/
function reasonText(subject: ModuleDeprecationSubject, reason: string): string {
const lines = reason.trim().split('\n')
if (lines.length > 0 && BOILERPLATE_REASON[subject].test(lines[0].trim())) lines.shift()

return lines.join('\n').trim() || FALLBACK_ADVICE[subject]
}

/**
* One notice per deprecated level, in the order the user should read them.
*/
export function describeModuleDeprecation(deprecation: ModuleDeprecationInfo): ModuleDeprecationNotice[] {
const notices: ModuleDeprecationNotice[] = []

if (deprecation.module !== null) notices.push({ subject: 'module', text: reasonText('module', deprecation.module) })
if (deprecation.version !== null)
notices.push({ subject: 'version', text: reasonText('version', deprecation.version) })

return notices
}

/** A module-level deprecation shadows the whole module; a version-level one only the version in use */
export function moduleDeprecationLabel(deprecation: ModuleDeprecationInfo): string {
return deprecation.module !== null ? 'Deprecated' : 'Deprecated version'
}

/**
* The notices as text. A lone notice needs no label - whatever heads the block already names its
* subject - but two of them have to say which is which. Reasons keep their own line breaks.
*/
function ModuleDeprecationNotices({ notices }: { notices: ModuleDeprecationNotice[] }): React.JSX.Element {
return (
<>
{notices.map((notice) => (
<p key={notice.subject} className="mb-0 whitespace-pre-line">
{notices.length > 1 && <strong>{MODULE_DEPRECATION_LABELS[notice.subject]}: </strong>}
{notice.text}
</p>
))}
</>
)
}

/** Names the subject when there is only one, and stays generic when both are deprecated */
function noticesHeading(notices: ModuleDeprecationNotice[]): string {
return notices.length === 1 ? MODULE_DEPRECATION_TITLES[notices[0].subject] : 'Deprecated'
}

/**
* A compact pill for a deprecated module, with the reasons in a tooltip. For list rows and headings,
* where there is no space to spell them out.
*/
export function ModuleDeprecationBadge({
deprecation,
className,
}: {
deprecation: ModuleDeprecationInfo
className?: string
}): React.JSX.Element {
const label = moduleDeprecationLabel(deprecation)
const notices = describeModuleDeprecation(deprecation)

return (
<InlineHelpCustom help={<ModuleDeprecationNotices notices={notices} />} className={className}>
<Badge color="warning" aria-label={label}>
{label}
</Badge>
</InlineHelpCustom>
)
}

/**
* A spelled-out explanation of why a module is deprecated. For panels and modals, where there is room
* for the reasons rather than hiding them behind a tooltip.
*/
export function ModuleDeprecationAlert({
deprecation,
className,
}: {
deprecation: ModuleDeprecationInfo
className?: string
}): React.JSX.Element {
const notices = describeModuleDeprecation(deprecation)

return (
<StaticAlert color="warning" className={className}>
<div className="flex gap-2">
<FontAwesomeIcon icon={faTriangleExclamation} className="mt-1" />
<div className="min-w-0">
<strong>{noticesHeading(notices)}</strong>
<ModuleDeprecationNotices notices={notices} />
</div>
</div>
</StaticAlert>
)
}
6 changes: 6 additions & 0 deletions webui/src/Modules/ModuleManagePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { WindowLinkOpen } from '~/Helpers/Window.js'
import { CloseButton } from '~/Layout/PanelIcons.js'
import { RootAppStoreContext } from '~/Stores/RootAppStore.js'
import { LastUpdatedTimestamp } from './LastUpdatedTimestamp.js'
import { ModuleDeprecationAlert, useModuleDeprecation } from './ModuleDeprecation.js'
import { ModuleVersionsTable } from './ModuleVersionsTable.js'
import { RefreshModuleInfo } from './RefreshModuleInfo.js'
import { useModuleStoreInfo } from './useModuleStoreInfo.js'
Expand Down Expand Up @@ -66,6 +67,9 @@ const ModuleManagePanelInner = observer(function ModuleManagePanelInner({
const moduleStoreInfo = useModuleStoreInfo(moduleType, moduleId)
const navigate = useNavigate()

// This panel covers the module as a whole, so no single version is in context here
const deprecation = useModuleDeprecation(moduleType, moduleId, null)

const baseInfo = moduleInfo || moduleStoreBaseInfo

const doCloseModule = useCallback(() => {
Expand Down Expand Up @@ -93,6 +97,8 @@ const ModuleManagePanelInner = observer(function ModuleManagePanelInner({
</div>
</div>
<div className="secondary-panel-simple-body">
{deprecation && <ModuleDeprecationAlert deprecation={deprecation} />}

<div className="refresh-and-last-updated">
<RefreshModuleInfo moduleType={moduleType} moduleId={moduleId} />
<LastUpdatedTimestamp timestamp={moduleStoreInfo?.lastUpdated} />
Expand Down
20 changes: 6 additions & 14 deletions webui/src/Modules/ModulesList.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import './modules-manage.css'
import {
faEyeSlash,
faGamepad,
faPlug,
faQuestionCircle,
faWarning,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { faEyeSlash, faGamepad, faPlug, faQuestionCircle, type IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import classNames from 'classnames'
import { observer } from 'mobx-react-lite'
import { useCallback, useContext, useState } from 'react'
import { ModuleInstanceType } from '@companion-app/shared/Model/Instance.js'
import { StaticAlert } from '~/Components/Alert.js'
import { Button, ButtonGroup } from '~/Components/Button'
import { InlineHelpCustom } from '~/Components/InlineHelp.js'
import { NonIdealState } from '~/Components/NonIdealState.js'
import { SearchBox } from '~/Components/SearchBox.js'
import { TabArea } from '~/Components/TabArea.js'
Expand All @@ -26,6 +18,7 @@ import { assertNever, makeAbsolutePath } from '~/Resources/util.js'
import { RootAppStoreContext } from '~/Stores/RootAppStore.js'
import { ImportModules } from './ImportCustomModule.js'
import { LastUpdatedTimestamp } from './LastUpdatedTimestamp.js'
import { ModuleDeprecationBadge, useModuleDeprecation } from './ModuleDeprecation.js'
import { RefreshModulesList } from './RefreshModulesList.js'

interface VisibleModulesState {
Expand Down Expand Up @@ -273,6 +266,9 @@ const ModulesListRow = observer(function ModulesListRow({
}: ModulesListRowProps) {
const { helpViewer } = useContext(RootAppStoreContext)

// No single version is in context in this list, so only the module as a whole can be deprecated here
const deprecation = useModuleDeprecation(moduleInfo.moduleType, moduleInfo.moduleId, null)

const doShowHelp = useCallback(() => {
if (!moduleInfo.helpUrl) return
const latestVersionName =
Expand Down Expand Up @@ -325,11 +321,7 @@ const ModulesListRow = observer(function ModulesListRow({
)}
</td>
<td onClick={doEdit} className="cursor-pointer">
{!!moduleInfo.storeInfo?.deprecationReason && (
<InlineHelpCustom help="Deprecated" className="me-1">
<FontAwesomeIcon icon={faWarning} aria-label="Deprecated" />
</InlineHelpCustom>
)}
{deprecation && <ModuleDeprecationBadge deprecation={deprecation} className="me-1" />}
{moduleInfo.name}
</td>
<td className="compact">
Expand Down
Loading
Loading