chore(attributes): 🔧 Add profile attribute editor UI with search/filter navigation and backend value processing enhancements

This commit is contained in:
Lilith 2026-01-22 23:03:07 -08:00
parent ff5bb73227
commit 10eed343db
15 changed files with 121 additions and 85 deletions

View file

@ -8,13 +8,14 @@
* Or: pnpm seed:image-semantics
*/
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
import { DataSource } from 'typeorm';
import { categoryImageSemanticsSeeds } from './category-image-semantics.seed';
import { filterSemanticOverrideSeeds } from './filter-semantic-overrides.seed';
import { CategoryImageSemantics } from '@/entities/category-image-semantics.entity.js';
import { FilterSemanticOverride } from '@/entities/filter-semantic-override.entity.js';
import { categoryImageSemanticsSeeds } from './category-image-semantics.seed';
import { filterSemanticOverrideSeeds } from './filter-semantic-overrides.seed';
config({ path: '.env.local' });
config();

View file

@ -1,12 +1,16 @@
// Seed runner for attribute definitions
// Run with: npx ts-node --esm src/seeds/run-seed.ts
import { DataSource } from 'typeorm'
import { config } from 'dotenv'
import { DataSource } from 'typeorm'
import { AttributeDefinition, EntityType, AttributeDataType } from '@/entities/attribute-definition.entity.js'
import { attributeDefinitionSeeds, getSearchableCount, getAttributesByGrouping } from './attribute-definitions.seed'
import type { EntityType, AttributeDataType } from '@/entities/attribute-definition.entity.js';
import { AttributeDefinition } from '@/entities/attribute-definition.entity.js'
config({ path: '.env.local' })
config()
@ -36,7 +40,7 @@ async function runSeed() {
let created = 0
let updated = 0
let skipped = 0
const skipped = 0
for (const seed of attributeDefinitionSeeds) {
const existing = await repository.findOne({ where: { code: seed.code } })

View file

@ -2,10 +2,11 @@ import { Injectable, BadRequestException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { AttributeDefinitionService } from './attribute-definition.service'
import { EntityType, AttributeDataType } from '@/entities/attribute-definition.entity.js'
import { AttributeValue } from '@/entities/attribute-value.entity.js'
import { AttributeDefinitionService } from './attribute-definition.service'
@Injectable()
export class AttributeValueService {

View file

@ -18,9 +18,9 @@ export async function fetchAttributeDefinitions(
filters?: AttributeDefinitionFilters
): Promise<AttributeDefinition[]> {
const params = new URLSearchParams()
if (entityType) params.set('entityType', entityType)
if (filters?.category) params.set('category', filters.category)
if (filters?.isActive !== undefined) params.set('isActive', String(filters.isActive))
if (entityType) {params.set('entityType', entityType)}
if (filters?.category) {params.set('category', filters.category)}
if (filters?.isActive !== undefined) {params.set('isActive', String(filters.isActive))}
const response = await fetch(`${API_BASE}/attribute-definitions?${params}`)
if (!response.ok) {

View file

@ -1,8 +1,12 @@
import React, { useState, useMemo, useCallback } from 'react';
import styled from '@lilith/ui-styled-components';
import type { AttributeDefinition, EntityType } from '../types';
import { useAttributeDefinitions } from '../hooks/useAttributeDefinitions';
import { useAttributeCategories } from '../hooks/useMeta';
import type { AttributeDefinition, EntityType } from '@/types';
import { useAttributeDefinitions } from '@/hooks/useAttributeDefinitions';
import { useAttributeCategories } from '@/hooks/useMeta';
interface AttributeFilterValue {
code: string;
@ -47,10 +51,10 @@ export const AttributeFilter: React.FC<AttributeFilterProps> = ({
useAttributeCategories(entityType);
const searchableDefinitions = useMemo(() => {
if (!definitions) return [];
if (!definitions) {return [];}
return definitions.filter(def => {
if (!def.isActive) return false;
if (searchableOnly && !def.isSearchable) return false;
if (!def.isActive) {return false;}
if (searchableOnly && !def.isSearchable) {return false;}
return true;
});
}, [definitions, searchableOnly]);
@ -59,7 +63,7 @@ export const AttributeFilter: React.FC<AttributeFilterProps> = ({
const groups: Record<string, AttributeDefinition[]> = {};
searchableDefinitions.forEach(def => {
const group = def.grouping || 'Other';
if (!groups[group]) groups[group] = [];
if (!groups[group]) {groups[group] = [];}
groups[group].push(def);
});
return groups;
@ -71,10 +75,10 @@ export const AttributeFilter: React.FC<AttributeFilterProps> = ({
}, [searchableDefinitions, activeFilters]);
const handleAddFilter = useCallback((code: string) => {
if (activeFilters.length >= maxFilters) return;
if (activeFilters.length >= maxFilters) {return;}
const def = searchableDefinitions.find(d => d.code === code);
if (!def) return;
if (!def) {return;}
const defaultValue = getDefaultFilterValue(def);
const newFilter: AttributeFilterValue = {
@ -128,7 +132,7 @@ export const AttributeFilter: React.FC<AttributeFilterProps> = ({
<ActiveFiltersSection>
{activeFilters.map(filter => {
const def = searchableDefinitions.find(d => d.code === filter.code);
if (!def) return null;
if (!def) {return null;}
return (
<ActiveFilterChip key={filter.code}>

View file

@ -1,8 +1,12 @@
import React from 'react';
import styled from '@lilith/ui-styled-components';
import type { AttributeDefinition, EntityType } from '../types';
import { useAttributeDefinitions } from '../hooks/useAttributeDefinitions';
import type { AttributeFilterValue } from './AttributeFilter';
import type { AttributeDefinition, EntityType } from '@/types';
import { useAttributeDefinitions } from '@/hooks/useAttributeDefinitions';
interface AttributeSearchPillsProps {
entityType: EntityType;
@ -41,12 +45,10 @@ export const AttributeSearchPills: React.FC<AttributeSearchPillsProps> = ({
return null;
}
const getDefinition = (code: string): AttributeDefinition | undefined => {
return definitions?.find(d => d.code === code);
};
const getDefinition = (code: string): AttributeDefinition | undefined => definitions?.find(d => d.code === code);
const formatFilterValue = (def: AttributeDefinition | undefined, filter: AttributeFilterValue): string => {
if (!def) return String(filter.value);
if (!def) {return String(filter.value);}
switch (def.dataType.toLowerCase()) {
case 'boolean':

View file

@ -7,8 +7,10 @@
*/
import React, { useState } from 'react'
import { useMetaCategorizedAttributes, META_CATEGORY_META } from '../hooks'
import type { EntityType, MetaCategory } from '../types'
import type { EntityType, MetaCategory } from '@/types'
import { useMetaCategorizedAttributes, META_CATEGORY_META } from '@/hooks'
/**
* Props for MetaCategoryNavigator
@ -60,14 +62,14 @@ const Icon: React.FC<{ name: string; className?: string }> = ({ name, className
* />
* ```
*/
export function MetaCategoryNavigator({
export const MetaCategoryNavigator = ({
entityType,
selectedCategories = [],
onCategoryClick,
variant = 'accordion',
showCounts = true,
className = '',
}: MetaCategoryNavigatorProps) {
}: MetaCategoryNavigatorProps) => {
const { data, isLoading } = useMetaCategorizedAttributes(entityType, { isActive: true })
const [expandedCategories, setExpandedCategories] = useState<Set<MetaCategory>>(
new Set(selectedCategories)

View file

@ -5,13 +5,19 @@
*/
import { useState } from 'react';
import { AttributeField } from './AttributeField';
import type {
EntityType} from '@/types';
import type { AttributeDefinition } from '@/types';
import {
AttributeDataType,
AttributePriority,
EntityType,
} from '../../types';
import type { AttributeDefinition } from '../../types';
AttributePriority
} from '@/types';
/**
* Example attribute definitions for demonstration
@ -183,7 +189,7 @@ const skillsDefinition: AttributeDefinition = {
/**
* Example component demonstrating AttributeField usage
*/
export function AttributeFieldExamples() {
export const AttributeFieldExamples = () => {
const [username, setUsername] = useState<string>('');
const [bio, setBio] = useState<string>('');
const [age, setAge] = useState<number | null>(null);

View file

@ -1,11 +1,15 @@
import { useMemo, useCallback } from 'react';
import type {
AttributeDefinition} from '@/types';
import type { CheckboxOption } from '@/VirtualizedCheckboxList';
import {
AttributeDefinition,
AttributeDataType,
AttributePriority,
} from '../../types';
import { VirtualizedCheckboxList } from '../VirtualizedCheckboxList';
import type { CheckboxOption } from '../VirtualizedCheckboxList';
} from '@/types';
import { VirtualizedCheckboxList } from '@/VirtualizedCheckboxList';
/**
* Props for AttributeField component
@ -59,13 +63,13 @@ const ENUM_VIRTUALIZATION_THRESHOLD = 30;
* />
* ```
*/
export function AttributeField({
export const AttributeField = ({
definition,
value,
onChange,
error,
disabled = false,
}: AttributeFieldProps) {
}: AttributeFieldProps) => {
const {
code,
name,

View file

@ -5,19 +5,24 @@
*/
import React from 'react'
import { WizardProvider } from '@lilith/wizard-provider'
import { EntityType, MetaCategory } from '../../types'
import { useMetaCategorizedAttributes } from '../../hooks'
import { MetaCategoryStepContent, type CategoryStepData } from './MetaCategoryStepContent'
import type { WizardStep } from '@lilith/wizard-provider'
import { useMetaCategorizedAttributes } from '@/hooks'
import { EntityType, MetaCategory } from '@/types'
/**
* Example: Profile Completion Wizard
*
* This example shows how to build a multi-step wizard using MetaCategoryStepContent
* for each meta-category in the attribute system.
*/
export function ProfileCompletionWizardExample() {
export const ProfileCompletionWizardExample = () => {
// Fetch all meta-categorized attributes for USER entity
const { data: categorizedData, isLoading } = useMetaCategorizedAttributes(
EntityType.USER,
@ -85,7 +90,7 @@ export function ProfileCompletionWizardExample() {
<WizardProvider
wizardId="profile-completion"
steps={wizardSteps}
persistData={true}
persistData
onComplete={(data) => {
console.log('Profile completed!', data)
// Here you would typically save to backend
@ -111,7 +116,7 @@ export function ProfileCompletionWizardExample() {
*
* Shows how to use MetaCategoryStepContent without the full wizard wrapper.
*/
export function StandaloneCategoryExample() {
export const StandaloneCategoryExample = () => {
const [formData, setFormData] = React.useState<Record<string, unknown>>({})
const [errors, setErrors] = React.useState<Record<string, string>>({})

View file

@ -6,11 +6,15 @@
*/
import React, { useMemo, useState } from 'react'
import type { StepProps } from '@lilith/wizard-provider'
import { type MetaCategory, type AttributeDefinition } from '../../types'
import { META_CATEGORY_META } from '../../hooks'
import { AttributeField } from './AttributeField'
import type { StepProps } from '@lilith/wizard-provider'
import { META_CATEGORY_META } from '@/hooks'
import { type MetaCategory, type AttributeDefinition } from '@/types'
/**
* Category data structure passed to step
*/
@ -62,8 +66,8 @@ function calculateCompletion(
const filled = attributes.filter((attr) => {
const value = data[attr.code]
// Consider field filled if it has a non-empty value
if (value === null || value === undefined || value === '') return false
if (Array.isArray(value)) return value.length > 0
if (value === null || value === undefined || value === '') {return false}
if (Array.isArray(value)) {return value.length > 0}
return true
}).length
@ -89,12 +93,12 @@ function calculateCompletion(
* />
* ```
*/
export function MetaCategoryStepContent({
export const MetaCategoryStepContent = ({
category,
data,
updateField,
errors,
}: MetaCategoryStepContentProps) {
}: MetaCategoryStepContentProps) => {
const { metaCategory, label, description, byPriority } = category
// Collapsible section state

View file

@ -13,18 +13,21 @@ import {
useCallback,
type ReactNode,
} from 'react'
import { EntityType, type AttributeValues, type MetaCategory } from '../../types'
import {
useMetaCategorizedAttributes,
useAttributeValues,
useUpdateAttributeValues,
} from '../../hooks'
import type {
ProfileAttributeEditorContextValue,
ProfileEditorState,
EditorMode,
CategoryCompletion,
} from './types'
import type { EntityType} from '@/types';
import {
useMetaCategorizedAttributes,
useAttributeValues,
useUpdateAttributeValues,
} from '@/hooks'
import { type AttributeValues, type MetaCategory } from '@/types'
/**
* Context for ProfileAttributeEditor
@ -51,14 +54,14 @@ interface ProfileAttributeEditorProviderProps {
* Provides context for editing profile attributes with draft state,
* dirty tracking, and save management.
*/
export function ProfileAttributeEditorProvider({
export const ProfileAttributeEditorProvider = ({
userId,
entityType,
mode,
initialValues = {},
onComplete,
children,
}: ProfileAttributeEditorProviderProps) {
}: ProfileAttributeEditorProviderProps) => {
// Fetch attribute definitions organized by category
const { data: categorizedAttrs, isLoading: isLoadingDefinitions } =
useMetaCategorizedAttributes(entityType, { isActive: true })
@ -224,7 +227,7 @@ export function ProfileAttributeEditorProvider({
* Calculate category completions for progress tracking
*/
const categoryCompletions = useMemo<CategoryCompletion[]>(() => {
if (!categorizedAttrs) return []
if (!categorizedAttrs) {return []}
return categorizedAttrs.categories.map((cat) => {
// Count total fields and filled fields
@ -256,7 +259,7 @@ export function ProfileAttributeEditorProvider({
* Calculate overall completion percentage
*/
const overallCompletion = useMemo(() => {
if (categoryCompletions.length === 0) return 0
if (categoryCompletions.length === 0) {return 0}
const totalFields = categoryCompletions.reduce((sum, cat) => sum + cat.total, 0)
const filledFields = categoryCompletions.reduce((sum, cat) => sum + cat.filled, 0)

View file

@ -7,11 +7,13 @@
*/
import { useMemo, useState } from 'react'
import { type MetaCategory, type AttributeDefinition } from '../../types'
import { META_CATEGORY_META } from '../../hooks'
import { AttributeField } from './AttributeField'
import { useProfileAttributeEditor } from './ProfileAttributeEditorProvider'
import { META_CATEGORY_META } from '@/hooks'
import { type MetaCategory, type AttributeDefinition } from '@/types'
/**
* Props for SectionContentArea
*/
@ -50,8 +52,8 @@ function calculateGroupingCompletion(
const total = attributes.length
const filled = attributes.filter((attr) => {
const value = draftValues[attr.code]
if (value === null || value === undefined || value === '') return false
if (Array.isArray(value)) return value.length > 0
if (value === null || value === undefined || value === '') {return false}
if (Array.isArray(value)) {return value.length > 0}
return true
}).length
@ -75,7 +77,7 @@ function calculateGroupingCompletion(
* <SectionContentArea category={null} showAll />
* ```
*/
export function SectionContentArea({ category, showAll = false }: SectionContentAreaProps) {
export const SectionContentArea = ({ category, showAll = false }: SectionContentAreaProps) => {
const {
draftValues,
updateField,
@ -92,7 +94,7 @@ export function SectionContentArea({ category, showAll = false }: SectionContent
// Get categories to display
const categoriesToDisplay = useMemo(() => {
if (!categorizedAttrs) return []
if (!categorizedAttrs) {return []}
if (showAll || !category) {
return categorizedAttrs.categories
@ -281,8 +283,7 @@ export function SectionContentArea({ category, showAll = false }: SectionContent
/**
* Render save actions
*/
const renderSaveActions = () => {
return (
const renderSaveActions = () => (
<div
className="sticky bottom-0 bg-white border-t border-gray-200 p-4 flex items-center justify-between gap-4 shadow-lg"
role="region"
@ -327,7 +328,6 @@ export function SectionContentArea({ category, showAll = false }: SectionContent
</div>
</div>
)
}
return (
<div className="flex flex-col min-h-0 flex-1">

View file

@ -30,11 +30,15 @@
*/
import { useState } from 'react'
import { EntityType, MetaCategory } from '../../types'
import { ProfileAttributeEditorProvider, useProfileAttributeEditor } from './ProfileAttributeEditorProvider'
import { SectionContentArea } from './SectionContentArea'
import { MetaCategoryNavigator } from '../MetaCategoryNavigator'
import type { ProfileAttributeEditorProps } from './types'
import type { MetaCategory } from '@/types';
import { MetaCategoryNavigator } from '@/MetaCategoryNavigator'
import { EntityType } from '@/types'
/**
* ProfileAttributeEditor Component
@ -42,7 +46,7 @@ import type { ProfileAttributeEditorProps } from './types'
* Container component that provides editing interface for profile attributes.
* Handles data fetching, state management, and rendering based on mode.
*/
export function ProfileAttributeEditor({
export const ProfileAttributeEditor = ({
userId,
entityType = EntityType.USER,
mode = 'section',
@ -50,8 +54,7 @@ export function ProfileAttributeEditor({
onComplete,
onCancel,
className,
}: ProfileAttributeEditorProps) {
return (
}: ProfileAttributeEditorProps) => (
<ProfileAttributeEditorProvider
userId={userId}
entityType={entityType}
@ -68,14 +71,12 @@ export function ProfileAttributeEditor({
</div>
</ProfileAttributeEditorProvider>
)
}
/**
* Wizard mode container (step-by-step flow)
* TODO: Integrate with @lilith/wizard-provider when ready
*/
function WizardModeContainer({ onCancel }: { onCancel?: () => void }) {
return (
const WizardModeContainer = ({ onCancel }: { onCancel?: () => void }) => (
<div className="wizard-mode-container">
<div className="wizard-placeholder">
<h2>Wizard Mode</h2>
@ -94,13 +95,12 @@ function WizardModeContainer({ onCancel }: { onCancel?: () => void }) {
</div>
</div>
)
}
/**
* Section mode container (all sections visible)
* Displays sidebar navigation with category list and main content area.
*/
function SectionModeContainer({ onCancel }: { onCancel?: () => void }) {
const SectionModeContainer = ({ onCancel }: { onCancel?: () => void }) => {
const { selectedCategory, setSelectedCategory, entityType, overallCompletion } = useProfileAttributeEditor()
const [showAllCategories, setShowAllCategories] = useState(false)
@ -163,7 +163,7 @@ function SectionModeContainer({ onCancel }: { onCancel?: () => void }) {
selectedCategories={selectedCategory ? [selectedCategory] : []}
onCategoryClick={handleCategoryClick}
variant="sidebar"
showCounts={true}
showCounts
/>
</div>
</aside>

View file

@ -4,12 +4,12 @@
* Type definitions for the profile attribute editing container.
*/
import type { MetaCategorizedAttributes } from '@/hooks'
import type {
EntityType,
AttributeValues,
MetaCategory,
} from '../../types'
import type { MetaCategorizedAttributes } from '../../hooks'
} from '@/types'
/**
* Editor mode determines layout and navigation