diff --git a/lib/src/Components/Profile/ComponentErrorBoundary.tsx b/lib/src/Components/Profile/ComponentErrorBoundary.tsx new file mode 100644 index 00000000..2455ae8e --- /dev/null +++ b/lib/src/Components/Profile/ComponentErrorBoundary.tsx @@ -0,0 +1,56 @@ +import { Component } from 'react' + +import type { ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode + componentName?: string +} + +interface State { + hasError: boolean + error?: Error +} + +/** + * Error boundary for profile components. + * Catches errors in child components and displays a fallback UI instead of crashing the entire profile. + */ +export class ComponentErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // eslint-disable-next-line no-console + console.error( + `Error in component ${this.props.componentName ?? 'unknown'}:`, + error, + errorInfo.componentStack, + ) + } + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + return ( +
+

Failed to load component

+ {this.props.componentName && ( +

{this.props.componentName}

+ )} +
+ ) + } + + return this.props.children + } +} diff --git a/lib/src/Components/Profile/Subcomponents/ProfileTagsForm.tsx b/lib/src/Components/Profile/Subcomponents/ProfileTagsForm.tsx index 46241269..fa430350 100644 --- a/lib/src/Components/Profile/Subcomponents/ProfileTagsForm.tsx +++ b/lib/src/Components/Profile/Subcomponents/ProfileTagsForm.tsx @@ -16,14 +16,18 @@ export const ProfileTagsForm = ({ state, setState, dataField, heading, placehold const defaultHeading = dataField === 'offers' ? 'Offers' : 'Needs' const defaultPlaceholder = dataField === 'offers' ? 'enter your offers' : 'enter your needs' + // Validate that defaultTags is an array + // eslint-disable-next-line security/detect-object-injection + const rawTags = state[dataField] + const defaultTags = Array.isArray(rawTags) ? rawTags : [] + return (

{heading ?? defaultHeading}

{ setState((prevState) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment diff --git a/lib/src/Components/Profile/Subcomponents/ProfileTagsView.tsx b/lib/src/Components/Profile/Subcomponents/ProfileTagsView.tsx index 29ea74d3..b3e086ae 100644 --- a/lib/src/Components/Profile/Subcomponents/ProfileTagsView.tsx +++ b/lib/src/Components/Profile/Subcomponents/ProfileTagsView.tsx @@ -18,10 +18,17 @@ export const ProfileTagsView = ({ item, dataField, heading, hideWhenEmpty = true // Get the tag IDs from the item based on dataField // eslint-disable-next-line security/detect-object-injection - const tagRelations = item[dataField] ?? [] + const rawTagRelations = item[dataField] - // Resolve tag IDs to full Tag objects + // Validate that tagRelations is an array + const tagRelations = Array.isArray(rawTagRelations) ? rawTagRelations : [] + + // Resolve tag IDs to full Tag objects, filtering out malformed entries const tags: Tag[] = tagRelations.reduce((acc: Tag[], relation) => { + // Skip if relation is missing tags_id (runtime validation for external data) + if (typeof relation !== 'object' || !('tags_id' in relation)) { + return acc + } const tag = allTags.find((t) => t.id === relation.tags_id) if (tag) acc.push(tag) return acc diff --git a/lib/src/Components/Profile/Subcomponents/TabsContainerForm.tsx b/lib/src/Components/Profile/Subcomponents/TabsContainerForm.tsx index 3bc665a5..de90678c 100644 --- a/lib/src/Components/Profile/Subcomponents/TabsContainerForm.tsx +++ b/lib/src/Components/Profile/Subcomponents/TabsContainerForm.tsx @@ -1,36 +1,13 @@ import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm' -import { CrowdfundingForm } from '#components/Profile/Subcomponents/CrowdfundingForm' -import { GalleryForm } from '#components/Profile/Subcomponents/GalleryForm' -import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm' -import { ProfileStartEndForm } from '#components/Profile/Subcomponents/ProfileStartEndForm' -import { ProfileTagsForm } from '#components/Profile/Subcomponents/ProfileTagsForm' -import { ProfileTextForm } from '#components/Profile/Subcomponents/ProfileTextForm' +import { ComponentErrorBoundary } from '#components/Profile/ComponentErrorBoundary' +import { formComponentMap } from '#components/Profile/componentMaps' import type { FormState } from '#types/FormState' import type { Item } from '#types/Item' import type { Key } from 'react' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ComponentMap = Record> - -const componentMap: ComponentMap = { - groupSubheaders: GroupSubheaderForm, - texts: ProfileTextForm, - contactInfos: ContactInfoForm, - startEnd: ProfileStartEndForm, - crowdfundings: CrowdfundingForm, - gallery: GalleryForm, - inviteLinks: () => null, - relations: () => null, // Relations are not editable in form - // eslint-disable-next-line camelcase -- Keys match external data schema - tags_component: ProfileTagsForm, - // eslint-disable-next-line camelcase -- Keys match external data schema - attestations_component: () => null, // Attestations are view-only -} - interface TabItem { id: string title: string @@ -95,6 +72,7 @@ export const TabsContainerForm = ({ item, state, setState, tabs, iconAsLabels = updateActiveTab(index) }} onKeyDown={(e) => { + // Prevent form submission on Enter if (e.key === 'Enter') { e.preventDefault() updateActiveTab(index) @@ -111,16 +89,17 @@ export const TabsContainerForm = ({ item, state, setState, tabs, iconAsLabels =
{/* eslint-disable-next-line security/detect-object-injection */} {tabs[activeTab].items.map((templateItem) => { - const TemplateComponent = componentMap[templateItem.collection] + const TemplateComponent = formComponentMap[templateItem.collection] // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return TemplateComponent ? ( - + + + ) : (
{templateItem.collection} form not found diff --git a/lib/src/Components/Profile/Subcomponents/TabsContainerView.tsx b/lib/src/Components/Profile/Subcomponents/TabsContainerView.tsx index c2815e42..93c5dfd9 100644 --- a/lib/src/Components/Profile/Subcomponents/TabsContainerView.tsx +++ b/lib/src/Components/Profile/Subcomponents/TabsContainerView.tsx @@ -1,38 +1,12 @@ import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { AttestationsView } from '#components/Profile/Subcomponents/AttestationsView' -import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoView' -import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView' -import { GalleryView } from '#components/Profile/Subcomponents/GalleryView' -import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView' -import { InviteLinkView } from '#components/Profile/Subcomponents/InviteLinkView' -import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView' -import { ProfileTagsView } from '#components/Profile/Subcomponents/ProfileTagsView' -import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView' -import { RelationsView } from '#components/Profile/Subcomponents/RelationsView' +import { ComponentErrorBoundary } from '#components/Profile/ComponentErrorBoundary' +import { viewComponentMap } from '#components/Profile/componentMaps' import type { Item } from '#types/Item' import type { Key } from 'react' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ComponentMap = Record> - -const componentMap: ComponentMap = { - groupSubheaders: GroupSubHeaderView, - texts: ProfileTextView, - contactInfos: ContactInfoView, - startEnd: ProfileStartEndView, - gallery: GalleryView, - crowdfundings: CrowdfundingView, - inviteLinks: InviteLinkView, - relations: RelationsView, - // eslint-disable-next-line camelcase -- Keys match external data schema - tags_component: ProfileTagsView, - // eslint-disable-next-line camelcase -- Keys match external data schema - attestations_component: AttestationsView, -} - interface TabItem { id: string title: string @@ -88,6 +62,7 @@ export const TabsContainerView = ({ item, tabs = [], iconAsLabels = false }: Pro
{tabs.map((tab, index) => (