From 23e5bd6032e9487ce0723e6dc5ef0930b41df488 Mon Sep 17 00:00:00 2001 From: Domagoj Gojak Date: Tue, 16 Sep 2025 16:25:21 +0200 Subject: [PATCH 1/3] Initial support for component page customization. --- dist/app/components/Component/Cards.d.ts | 25 ++++ dist/app/components/Component/Cards.jsx | 62 ++++++++++ dist/transformers/preview/component/css.d.ts | 1 + dist/transformers/preview/component/json.js | 1 + dist/transformers/preview/types.d.ts | 51 +++++++- dist/types.d.ts | 2 + dist/utils/filter.js | 49 +++++--- .../Component/BestPracticesCard.tsx | 62 +++++----- src/app/components/Component/Cards.tsx | 114 ++++++++++++++++++ .../Component/PageSliceResolver.tsx | 74 ++++++++++++ src/app/components/Component/Preview.tsx | 105 ++++++++-------- .../Component/slices/BestPracticesSlice.tsx | 14 +++ .../Component/slices/CardsSlice.tsx | 17 +++ .../slices/ComponentDisplaySlice.tsx | 79 ++++++++++++ .../Component/slices/PropertiesSlice.tsx | 24 ++++ .../components/Component/slices/TextSlice.tsx | 23 ++++ .../slices/ValidationResultsSlice.tsx | 31 +++++ src/app/components/Component/slices/index.ts | 14 +++ src/transformers/preview/component/json.ts | 1 + src/transformers/preview/types.ts | 67 +++++++++- src/types.ts | 2 + src/utils/filter.ts | 52 +++++--- 22 files changed, 740 insertions(+), 130 deletions(-) create mode 100644 dist/app/components/Component/Cards.d.ts create mode 100644 dist/app/components/Component/Cards.jsx create mode 100644 src/app/components/Component/Cards.tsx create mode 100644 src/app/components/Component/PageSliceResolver.tsx create mode 100644 src/app/components/Component/slices/BestPracticesSlice.tsx create mode 100644 src/app/components/Component/slices/CardsSlice.tsx create mode 100644 src/app/components/Component/slices/ComponentDisplaySlice.tsx create mode 100644 src/app/components/Component/slices/PropertiesSlice.tsx create mode 100644 src/app/components/Component/slices/TextSlice.tsx create mode 100644 src/app/components/Component/slices/ValidationResultsSlice.tsx create mode 100644 src/app/components/Component/slices/index.ts diff --git a/dist/app/components/Component/Cards.d.ts b/dist/app/components/Component/Cards.d.ts new file mode 100644 index 00000000..476ae0ee --- /dev/null +++ b/dist/app/components/Component/Cards.d.ts @@ -0,0 +1,25 @@ +import React from 'react'; +/** + * Card interface for the Cards component + */ +export interface Card { + /** Title of the card */ + title: string; + /** Content of the card (supports multi-line with \n separators) */ + content: string; + /** (Optional) Visual type of the card affecting icon and colors */ + type?: 'positive' | 'negative'; +} +/** + * Props for the Cards component + */ +export interface CardsProps { + /** Array of cards to display */ + cards: Card[]; + /** Maximum number of cards per row (1-2, default: 2) */ + maxCardsPerRow?: 1 | 2; + /** Additional CSS classes */ + className?: string; +} +declare const Cards: React.FC; +export default Cards; diff --git a/dist/app/components/Component/Cards.jsx b/dist/app/components/Component/Cards.jsx new file mode 100644 index 00000000..e93d1a36 --- /dev/null +++ b/dist/app/components/Component/Cards.jsx @@ -0,0 +1,62 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const react_1 = __importDefault(require("react")); +const CardIcon = ({ type }) => { + switch (type) { + case 'positive': + return ( + + ); + case 'negative': + return ( + + ); + default: + return <>; + } +}; +const CardItem = ({ card }) => { + // Split content by newlines and render each line as a separate item + const lines = card.content.split('\n').filter((line) => line.trim()); + return (); +}; +const Cards = ({ cards, maxCardsPerRow = 2, className = '' }) => { + if (!cards || cards.length === 0) { + return null; + } + // Calculate grid columns based on maxCardsPerRow (always full width, max 2 per row) + const getGridCols = () => { + if (maxCardsPerRow === 1) + return 'grid-cols-1'; + if (maxCardsPerRow === 2) + return 'grid-cols-1 sm:grid-cols-2'; + if (maxCardsPerRow === 3) + return 'grid-cols-1 sm:grid-cols-2'; // Cap at 2 per row + return 'grid-cols-1 sm:grid-cols-2'; // Default to max 2 per row + }; + return (
+
+ {cards.map((card, index) => (
+

+ {card.title} +

+
+ +
+
))} +
+
); +}; +exports.default = Cards; diff --git a/dist/transformers/preview/component/css.d.ts b/dist/transformers/preview/component/css.d.ts index 3ad70728..8aab81a9 100644 --- a/dist/transformers/preview/component/css.d.ts +++ b/dist/transformers/preview/component/css.d.ts @@ -40,6 +40,7 @@ declare const buildComponentCss: (data: TransformComponentTokensResult, handoff: }; }; validations?: Record; + page?: import("../types").ComponentPageDefinition; }>; /** * Build the main CSS file using Vite diff --git a/dist/transformers/preview/component/json.js b/dist/transformers/preview/component/json.js index da3d79e8..caaa79d2 100644 --- a/dist/transformers/preview/component/json.js +++ b/dist/transformers/preview/component/json.js @@ -41,6 +41,7 @@ const parseComponentJson = (id, location, data) => __awaiter(void 0, void 0, voi data.properties = parsed.properties; data.previews = parsed.previews; data.options = parsed.options; + data.page = parsed.page; } } catch (e) { diff --git a/dist/transformers/preview/types.d.ts b/dist/transformers/preview/types.d.ts index a7c4095a..0547ae83 100644 --- a/dist/transformers/preview/types.d.ts +++ b/dist/transformers/preview/types.d.ts @@ -1,3 +1,4 @@ +import { Card } from '../../app/components/Component/Cards'; import { ValidationResult } from '../../types'; import { Filter } from '../../utils/filter'; import { SlotMetadata } from './component'; @@ -7,6 +8,50 @@ export declare enum ComponentType { Navigation = "navigation", Utility = "utility" } +export type PageSliceType = 'BEST_PRACTICES' | 'COMPONENT_DISPLAY' | 'VALIDATION_RESULTS' | 'PROPERTIES' | 'TEXT' | 'CARDS'; +export interface BasePageSlice { + type: PageSliceType; +} +export interface BestPracticesPageSlice extends BasePageSlice { + type: 'BEST_PRACTICES'; +} +export interface ComponentDisplayPageSlice extends BasePageSlice { + type: 'COMPONENT_DISPLAY'; + showPreview?: boolean; + showCodeHighlight?: boolean; + defaultHeight?: string; + filterBy?: Filter; +} +export interface ValidationResultsPageSlice extends BasePageSlice { + type: 'VALIDATION_RESULTS'; +} +export interface PropertiesPageSlice extends BasePageSlice { + type: 'PROPERTIES'; +} +export interface TextPageSlice extends BasePageSlice { + type: 'TEXT'; + /** Optional title text (always rendered as H3) */ + title?: string; + /** Optional HTML content to render */ + content?: string; +} +export interface CardsPageSlice extends BasePageSlice { + type: 'CARDS'; + /** Array of cards to display */ + cards: Card[]; + /** Maximum number of cards per row (default: 2, max: 2, always full width) */ + maxCardsPerRow?: 1 | 2; +} +/** + * Discriminated union type for all page slices. + * Provides type safety by ensuring each slice type has its specific settings. + * TypeScript will narrow the type based on the 'type' discriminator property. + */ +export type PageSlice = BestPracticesPageSlice | ComponentDisplayPageSlice | ValidationResultsPageSlice | PropertiesPageSlice | TextPageSlice | CardsPageSlice; +export type ComponentPageDefinition = { + slices: PageSlice[]; + options?: Record; +}; export type ComponentListObject = { id?: string; version: string; @@ -49,6 +94,7 @@ export type ComponentListObject = { }; }; }; + page?: ComponentPageDefinition; }; export type TransformComponentTokensResult = { id: string; @@ -89,11 +135,8 @@ export type TransformComponentTokensResult = { groupBy?: string; }; }; - /** - * Validation results for the component - * Each key represents a validation type and the value contains detailed validation results - */ validations?: Record; + page?: ComponentPageDefinition; } | null; export type OptionalPreviewRender = { title: string; diff --git a/dist/types.d.ts b/dist/types.d.ts index 80e3f237..c6311065 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -1,5 +1,6 @@ import { Types as CoreTypes } from 'handoff-core'; import { SlotMetadata } from './transformers/preview/component'; +import { ComponentPageDefinition } from './transformers/preview/types'; import { type Filter } from './utils/filter'; export interface ValidationResult { /** @@ -70,6 +71,7 @@ export interface PreviewObject { * Each key represents a validation type and the value contains detailed validation results */ validations?: Record; + page?: ComponentPageDefinition; } export type PreviewJson = { components: { diff --git a/dist/utils/filter.js b/dist/utils/filter.js index 6b800dbd..e22591fb 100644 --- a/dist/utils/filter.js +++ b/dist/utils/filter.js @@ -12,31 +12,31 @@ function evaluateFilter(obj, filter) { var _a, _b, _c; // Handle array of field filters (implicit AND) if (Array.isArray(filter)) { - const results = filter.map(f => evaluateFieldFilter(obj, f)); + const results = filter.map((f) => evaluateFieldFilter(obj, f)); // If any filter doesn't match, return false - if (!results.every(r => r.matches)) { + if (!results.every((r) => r.matches)) { return { matches: false }; } // If any filter has an order, use the first one (since we're using AND) - const order = (_a = results.find(r => r.order !== undefined)) === null || _a === void 0 ? void 0 : _a.order; + const order = (_a = results.find((r) => r.order !== undefined)) === null || _a === void 0 ? void 0 : _a.order; return { matches: true, order }; } // Handle logical filter if ('and' in filter) { - const results = filter.and.map(f => evaluateFilter(obj, f)); - if (!results.every(r => r.matches)) { + const results = filter.and.map((f) => evaluateFilter(obj, f)); + if (!results.every((r) => r.matches)) { return { matches: false }; } - const order = (_b = results.find(r => r.order !== undefined)) === null || _b === void 0 ? void 0 : _b.order; + const order = (_b = results.find((r) => r.order !== undefined)) === null || _b === void 0 ? void 0 : _b.order; return { matches: true, order }; } if ('or' in filter) { - const results = filter.or.map(f => evaluateFilter(obj, f)); - if (!results.some(r => r.matches)) { + const results = filter.or.map((f) => evaluateFilter(obj, f)); + if (!results.some((r) => r.matches)) { return { matches: false }; } // For OR, we use the first matching order - const order = (_c = results.find(r => r.matches && r.order !== undefined)) === null || _c === void 0 ? void 0 : _c.order; + const order = (_c = results.find((r) => r.matches && r.order !== undefined)) === null || _c === void 0 ? void 0 : _c.order; return { matches: true, order }; } if ('not' in filter) { @@ -54,13 +54,22 @@ function evaluateFilter(obj, filter) { function evaluateFieldFilter(obj, filter) { const { field, op, value } = filter; const actual = obj[field]; + if (op === 'neq') { + console.log('EVAL', filter, actual, actual !== value); + } switch (op) { - case 'eq': return { matches: actual === value }; - case 'neq': return { matches: actual !== value }; - case 'gt': return { matches: actual > value }; - case 'gte': return { matches: actual >= value }; - case 'lt': return { matches: actual < value }; - case 'lte': return { matches: actual <= value }; + case 'eq': + return { matches: actual === value }; + case 'neq': + return { matches: actual !== value }; + case 'gt': + return { matches: actual > value }; + case 'gte': + return { matches: actual >= value }; + case 'lt': + return { matches: actual < value }; + case 'lte': + return { matches: actual <= value }; case 'contains': if (typeof actual === 'string') return { matches: actual.includes(value) }; @@ -72,8 +81,10 @@ function evaluateFieldFilter(obj, filter) { return { matches: false }; const index = value.indexOf(actual); return { matches: index !== -1, order: index }; - case 'nin': return { matches: Array.isArray(value) && !value.includes(actual) }; - default: return { matches: false }; + case 'nin': + return { matches: Array.isArray(value) && !value.includes(actual) }; + default: + return { matches: false }; } } /** @@ -83,9 +94,9 @@ function evaluateFieldFilter(obj, filter) { * @returns Filtered and sorted array of objects */ function filterAndSort(items, filter) { - const results = items.map(item => ({ + const results = items.map((item) => ({ item, - result: evaluateFilter(item, filter) + result: evaluateFilter(item, filter), })); return results .filter(({ result }) => result.matches) diff --git a/src/app/components/Component/BestPracticesCard.tsx b/src/app/components/Component/BestPracticesCard.tsx index 29a32b0d..84ecd434 100644 --- a/src/app/components/Component/BestPracticesCard.tsx +++ b/src/app/components/Component/BestPracticesCard.tsx @@ -1,43 +1,35 @@ import { PreviewObject } from '@handoff/types'; -import { Check, X } from 'lucide-react'; import React from 'react'; - -const PracticeLine: React.FC<{ rule: string; yes: boolean }> = ({ rule, yes }) => ( -
  • - {yes ? ( - - ) : ( - - )} -

    {rule}

    -
  • -); +import Cards, { Card } from './Cards'; const BestPracticesCard: React.FC<{ component: PreviewObject }> = ({ component }) => { + const cards: Card[] = []; + + // Add best practices card if available + if (component.should_do && component.should_do.length > 0) { + cards.push({ + title: 'Best Practices', + content: component.should_do.join('\n'), + type: 'positive' + }); + } + + // Add common mistakes card if available + if (component.should_not_do && component.should_not_do.length > 0) { + cards.push({ + title: 'Common Mistakes', + content: component.should_not_do.join('\n'), + type: 'negative' + }); + } + + if (cards.length === 0) { + return null; + } + return ( -
    -
    - {component.should_do && component.should_do.length > 0 && ( -
    -

    Best Practices

    -
      - {component.should_do.map((rule, index) => ( - - ))} -
    -
    - )} - {component.should_not_do && component.should_not_do.length > 0 && ( -
    -

    Common Mistakes

    -
      - {component.should_not_do.map((rule, index) => ( - - ))} -
    -
    - )} -
    +
    +
    ); }; diff --git a/src/app/components/Component/Cards.tsx b/src/app/components/Component/Cards.tsx new file mode 100644 index 00000000..818333d6 --- /dev/null +++ b/src/app/components/Component/Cards.tsx @@ -0,0 +1,114 @@ +import React from 'react'; + +/** + * Card interface for the Cards component + */ +export interface Card { + /** Title of the card */ + title: string; + /** Content of the card (supports multi-line with \n separators) */ + content: string; + /** (Optional) Visual type of the card affecting icon and colors */ + type?: 'positive' | 'negative'; +} + +/** + * Props for the Cards component + */ +export interface CardsProps { + /** Array of cards to display */ + cards: Card[]; + /** Maximum number of cards per row (1-2, default: 2) */ + maxCardsPerRow?: 1 | 2; + /** Additional CSS classes */ + className?: string; +} + +const CardIcon: React.FC<{ type: Card['type'] }> = ({ type }) => { + switch (type) { + case 'positive': + return ( + + + + ); + case 'negative': + return ( + + + + ); + default: + return <>; + } +}; + +const CardItem: React.FC<{ card: Card }> = ({ card }) => { + // Split content by newlines and render each line as a separate item + const lines = card.content.split('\n').filter((line) => line.trim()); + + return ( +
      + {lines.map((line, index) => ( +
    • + +

      {line}

      +
    • + ))} +
    + ); +}; + +const Cards: React.FC = ({ cards, maxCardsPerRow = 2, className = '' }) => { + if (!cards || cards.length === 0) { + return null; + } + + // Calculate grid columns based on maxCardsPerRow (always full width, max 2 per row) + const getGridCols = () => { + if (maxCardsPerRow === 1) return 'grid-cols-1'; + if (maxCardsPerRow === 2) return 'grid-cols-1 sm:grid-cols-2'; + if (maxCardsPerRow === 3) return 'grid-cols-1 sm:grid-cols-2'; // Cap at 2 per row + return 'grid-cols-1 sm:grid-cols-2'; // Default to max 2 per row + }; + + return ( +
    +
    + {cards.map((card, index) => ( +
    +

    + {card.title} +

    +
    + +
    +
    + ))} +
    +
    + ); +}; + +export default Cards; diff --git a/src/app/components/Component/PageSliceResolver.tsx b/src/app/components/Component/PageSliceResolver.tsx new file mode 100644 index 00000000..016314d2 --- /dev/null +++ b/src/app/components/Component/PageSliceResolver.tsx @@ -0,0 +1,74 @@ +import { PageSlice } from '@handoff/transformers/preview/types'; +import { PreviewObject } from '@handoff/types'; +import React from 'react'; +import { BestPracticesSlice, CardsSlice, ComponentDisplaySlice, PropertiesSlice, TextSlice, ValidationResultsSlice } from './slices'; + +export interface PageSliceResolverProps { + slice: PageSlice; + preview: PreviewObject; + title: string; + height?: string; + currentValues?: Record; + onValuesChange?: (values: Record) => void; + bestPracticesCard?: boolean; + codeHighlight?: boolean; + properties?: boolean; + validations?: boolean; +} + +export const PageSliceResolver: React.FC = ({ + slice, + preview, + title, + height, + currentValues, + onValuesChange, + bestPracticesCard = true, + codeHighlight = true, + properties = true, + validations = true, +}) => { + switch (slice.type) { + case 'BEST_PRACTICES': { + return ; + } + + case 'COMPONENT_DISPLAY': { + return ( + + ); + } + + case 'VALIDATION_RESULTS': { + return ; + } + + case 'PROPERTIES': { + return ; + } + + case 'TEXT': { + return ; + } + + case 'CARDS': { + return ; + } + + default: + return null; + } +}; + +export default PageSliceResolver; diff --git a/src/app/components/Component/Preview.tsx b/src/app/components/Component/Preview.tsx index 564e6408..39328e06 100644 --- a/src/app/components/Component/Preview.tsx +++ b/src/app/components/Component/Preview.tsx @@ -1,4 +1,5 @@ import { SlotMetadata } from '@handoff/transformers/preview/component'; +import { PageSlice } from '@handoff/transformers/preview/types'; import { PreviewObject } from '@handoff/types'; import { Types as CoreTypes } from 'handoff-core'; import { startCase } from 'lodash'; @@ -18,8 +19,6 @@ import React, { useCallback, useContext, useEffect } from 'react'; import { HotReloadContext } from '../context/HotReloadProvider'; import { usePreviewContext } from '../context/PreviewContext'; import RulesSheet from '../Foundations/RulesSheet'; -import { CodeHighlight } from '../Markdown/CodeHighlight'; -import HeadersType from '../Typography/Headers'; import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; @@ -27,8 +26,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { Separator } from '../ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; -import { ValidationResults } from '../Validation/ValidationResults'; -import BestPracticesCard from './BestPracticesCard'; +import PageSliceResolver from './PageSliceResolver'; + +const getDefaultSlices = (): PageSlice[] => [ + { type: 'BEST_PRACTICES' }, + { type: 'COMPONENT_DISPLAY' }, + { type: 'VALIDATION_RESULTS' }, + { type: 'PROPERTIES' }, +]; export type ComponentPreview = { component: CoreTypes.IComponentInstance; @@ -56,6 +61,32 @@ export const ComponentDisplay: React.FC<{ const [inspect, setInspect] = React.useState(false); const [scale, setScale] = React.useState(0.8); + // Generate variants from component previews if they differ from context + const localVariants = React.useMemo(() => { + if (!component?.previews) return null; + + // Check if component previews are different from context previews + const componentPreviewKeys = Object.keys(component.previews).sort(); + const contextPreviewKeys = context.preview ? Object.keys(context.preview.previews).sort() : []; + + if (JSON.stringify(componentPreviewKeys) !== JSON.stringify(contextPreviewKeys)) { + // Generate variants from component previews + const variantMap: Record> = {}; + Object.values(component.previews).forEach((preview: any) => { + Object.entries(preview.values).forEach(([key, value]) => { + if (!variantMap[key]) { + variantMap[key] = new Set(); + } + variantMap[key].add(String(value)); + }); + }); + + return Object.fromEntries(Object.entries(variantMap).map(([key, values]) => [key, Array.from(values)])); + } + + return context.variants; + }, [component?.previews, context.preview, context.variants]); + const onLoad = useCallback(() => { if (defaultHeight) { setHeight(defaultHeight); @@ -125,14 +156,14 @@ export const ComponentDisplay: React.FC<{ <>
    - {context.variants ? ( + {localVariants ? ( <> - {Object.keys(context.variants).length > 0 && ( + {Object.keys(localVariants).length > 0 && ( <>

    {title ?? 'Variant'}

    - {Object.keys(context.variants) - .filter((variantProperty) => context.variants[variantProperty].length > 1) + {Object.keys(localVariants) + .filter((variantProperty) => localVariants[variantProperty].length > 1) .map((variantProperty) => (