PR Feedback implemented

This commit is contained in:
Anton Tranelis 2025-12-17 07:55:00 +01:00
parent a3e405750a
commit 588802e1a5
9 changed files with 189 additions and 134 deletions

View File

@ -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<Props, State> {
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 (
<div className='tw:p-4 tw:text-error tw:bg-error/10 tw:rounded-lg tw:my-2'>
<p className='tw:font-semibold'>Failed to load component</p>
{this.props.componentName && (
<p className='tw:text-sm tw:opacity-70'>{this.props.componentName}</p>
)}
</div>
)
}
return this.props.children
}
}

View File

@ -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 (
<div className='tw:flex-1 tw:flex tw:flex-col tw:min-h-0'>
<h3 className='tw:text-base tw:font-semibold tw:mt-4 tw:mb-2 tw:flex-none'>
{heading ?? defaultHeading}
</h3>
<TagsWidget
// eslint-disable-next-line security/detect-object-injection
defaultTags={state[dataField]}
defaultTags={defaultTags}
onUpdate={(tags) => {
setState((prevState) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment

View File

@ -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

View File

@ -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<string, React.ComponentType<any>>
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 =
<div className='tw:flex-1 tw:flex tw:flex-col tw:min-h-0'>
{/* 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 ? (
<TemplateComponent
key={templateItem.id}
item={item}
state={state}
setState={setState}
{...templateItem.item}
/>
<ComponentErrorBoundary key={templateItem.id} componentName={templateItem.collection}>
<TemplateComponent
item={item}
state={state}
setState={setState}
{...templateItem.item}
/>
</ComponentErrorBoundary>
) : (
<div className='tw:mt-2 tw:flex-none' key={templateItem.id}>
{templateItem.collection} form not found

View File

@ -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<string, React.ComponentType<any>>
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
<div className='tw:flex tw:bg-base-200 tw:rounded-lg tw:p-1 tw:mb-4'>
{tabs.map((tab, index) => (
<button
type='button'
key={tab.id}
className={`tw:flex-1 tw:flex tw:items-center tw:justify-center tw:gap-2 tw:py-2 tw:px-4 tw:rounded-md tw:transition-colors tw:cursor-pointer ${activeTab === index ? 'tw:bg-primary tw:text-primary-content' : 'hover:tw:bg-base-300'}`}
onClick={() => {
@ -104,10 +79,12 @@ export const TabsContainerView = ({ item, tabs = [], iconAsLabels = false }: Pro
<div className='tw:overflow-y-auto fade tw:pb-4 tw:overflow-x-hidden'>
{/* eslint-disable-next-line security/detect-object-injection */}
{tabs[activeTab].items.map((templateItem) => {
const TemplateComponent = componentMap[templateItem.collection]
const TemplateComponent = viewComponentMap[templateItem.collection]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return TemplateComponent ? (
<TemplateComponent key={templateItem.id} item={item} {...templateItem.item} />
<ComponentErrorBoundary key={templateItem.id} componentName={templateItem.collection}>
<TemplateComponent item={item} {...templateItem.item} />
</ComponentErrorBoundary>
) : (
<div className='tw:mb-6' key={templateItem.id}>
{templateItem.collection} view not found

View File

@ -1,33 +1,9 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
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 { TabsContainerForm } from '#components/Profile/Subcomponents/TabsContainerForm'
import { ComponentErrorBoundary } from '#components/Profile/ComponentErrorBoundary'
import { formComponentMap } from '#components/Profile/componentMaps'
import type { FormState } from '#types/FormState'
import type { Item } from '#types/Item'
const componentMap = {
groupSubheaders: GroupSubheaderForm,
texts: ProfileTextForm,
contactInfos: ContactInfoForm,
startEnd: ProfileStartEndForm,
crowdfundings: CrowdfundingForm,
gallery: GalleryForm,
inviteLinks: () => null,
relations: () => null,
// 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,
tabs: TabsContainerForm,
}
export const FlexForm = ({
item,
state,
@ -40,15 +16,20 @@ export const FlexForm = ({
return (
<div className='tw:mt-6 tw:flex tw:flex-col tw:flex-1 tw:min-h-0'>
{item.layer?.itemType.profileTemplate.map((templateItem) => {
const TemplateComponent = componentMap[templateItem.collection]
const TemplateComponent = formComponentMap[templateItem.collection]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Key may not exist in map
return TemplateComponent ? (
<TemplateComponent
<ComponentErrorBoundary
key={templateItem.id}
state={state}
setState={setState}
item={item}
{...templateItem.item}
/>
componentName={String(templateItem.collection)}
>
<TemplateComponent
state={state}
setState={setState}
item={item}
{...templateItem.item}
/>
</ComponentErrorBoundary>
) : (
<div className='tw:mt-2 tw:flex-none' key={templateItem.id}>
{templateItem.collection} form not found

View File

@ -1,45 +1,21 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
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 { TabsContainerView } from '#components/Profile/Subcomponents/TabsContainerView'
import { ComponentErrorBoundary } from '#components/Profile/ComponentErrorBoundary'
import { viewComponentMap } from '#components/Profile/componentMaps'
import type { Item } from '#types/Item'
import type { Key } from 'react'
const 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,
tabs: TabsContainerView,
}
export const FlexView = ({ item }: { item: Item }) => {
return (
<div className='tw:h-full tw:overflow-y-auto fade tw:px-6 tw:py-4 tw:flex tw:flex-col tw:gap-4'>
{item.layer?.itemType.profileTemplate.map(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(templateItem: { collection: string | number; id: Key | null | undefined; item: any }) => {
const TemplateComponent = componentMap[templateItem.collection]
(templateItem: { collection: string; id: Key | null | undefined; item: any }) => {
const TemplateComponent = viewComponentMap[templateItem.collection]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Key may not exist in map
return TemplateComponent ? (
<TemplateComponent key={templateItem.id} item={item} {...templateItem.item} />
<ComponentErrorBoundary key={templateItem.id} componentName={templateItem.collection}>
<TemplateComponent item={item} {...templateItem.item} />
</ComponentErrorBoundary>
) : (
<div className='tw:mx-6 tw:mb-6' key={templateItem.id}>
{templateItem.collection} view not found

View File

@ -0,0 +1,63 @@
import { AttestationsView } from '#components/Profile/Subcomponents/AttestationsView'
import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm'
import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoView'
import { CrowdfundingForm } from '#components/Profile/Subcomponents/CrowdfundingForm'
import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView'
import { GalleryForm } from '#components/Profile/Subcomponents/GalleryForm'
import { GalleryView } from '#components/Profile/Subcomponents/GalleryView'
import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm'
import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView'
import { InviteLinkView } from '#components/Profile/Subcomponents/InviteLinkView'
import { ProfileStartEndForm } from '#components/Profile/Subcomponents/ProfileStartEndForm'
import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView'
import { ProfileTagsForm } from '#components/Profile/Subcomponents/ProfileTagsForm'
import { ProfileTagsView } from '#components/Profile/Subcomponents/ProfileTagsView'
import { ProfileTextForm } from '#components/Profile/Subcomponents/ProfileTextForm'
import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView'
import { RelationsView } from '#components/Profile/Subcomponents/RelationsView'
import { TabsContainerForm } from '#components/Profile/Subcomponents/TabsContainerForm'
import { TabsContainerView } from '#components/Profile/Subcomponents/TabsContainerView'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ComponentMap = Record<string, React.ComponentType<any>>
/**
* Component map for view mode (ProfileView, TabsContainerView)
* Maps collection names from the external data schema to React components
*/
export const viewComponentMap: ComponentMap = {
groupSubheaders: GroupSubHeaderView,
texts: ProfileTextView,
contactInfos: ContactInfoView,
startEnd: ProfileStartEndView,
gallery: GalleryView,
crowdfundings: CrowdfundingView,
inviteLinks: InviteLinkView,
relations: RelationsView,
tabs: TabsContainerView,
// 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,
}
/**
* Component map for form/edit mode (ProfileForm, TabsContainerForm)
* Maps collection names from the external data schema to React components
* Some components return null as they are view-only or not editable
*/
export const formComponentMap: ComponentMap = {
groupSubheaders: GroupSubheaderForm,
texts: ProfileTextForm,
contactInfos: ContactInfoForm,
startEnd: ProfileStartEndForm,
crowdfundings: CrowdfundingForm,
gallery: GalleryForm,
inviteLinks: () => null,
relations: () => null, // Relations are not editable in form
tabs: TabsContainerForm,
// 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
}

View File

@ -11,6 +11,18 @@ export interface Attestation {
to: { directus_users_id: string }[]
}
export const AttestationsContext = createContext<Attestation[]>([])
// Using undefined as default to detect missing provider
export const AttestationsContext = createContext<Attestation[] | undefined>(undefined)
export const useAttestations = () => useContext(AttestationsContext)
export const useAttestations = (): Attestation[] => {
const context = useContext(AttestationsContext)
if (context === undefined) {
// eslint-disable-next-line no-console
console.warn(
'useAttestations: AttestationsContext.Provider is missing. ' +
'Make sure the component is wrapped in AttestationsContext.Provider.',
)
return []
}
return context
}