diff --git a/mesh/mesh-integration/mesh-manifest.reference.json b/mesh/mesh-integration/mesh-manifest.reference.json index 501083b7..7379b20a 100644 --- a/mesh/mesh-integration/mesh-manifest.reference.json +++ b/mesh/mesh-integration/mesh-manifest.reference.json @@ -27,6 +27,7 @@ "displayName": "Reference Archetype", "dataEditorUrl": "/reference/dataResource", "typeEditorUrl": "/reference/dataType", + "dataResourceSelectorUrl": "/reference/dataResourceSelector", "typeEditorLocations": { "teDialog": { "url": "../namedDialog" @@ -121,7 +122,20 @@ { "id": "composition-editor-tool", "name": "Composition Editor Tool", - "url": "/reference/canvas-editor-tools", + "url": "/reference/canvasEditorTools", + "iconUrl": "/uniform.png" + }, + { + "id": "composition-editor-tool-2", + "name": "Composition Editor Tool 2", + "url": "/reference/canvasEditorTools", + "iconUrl": "/cat.svg", + "editorTypes": ["composition"] + }, + { + "id": "translation-tool", + "name": "Example Translation Tool", + "url": "/reference/canvasEditorToolsTranslation", "iconUrl": "/uniform.png" } ] diff --git a/mesh/mesh-integration/pages/reference/canvas-editor-tools.tsx b/mesh/mesh-integration/pages/reference/canvas-editor-tools.tsx deleted file mode 100644 index 4a1f5a2e..00000000 --- a/mesh/mesh-integration/pages/reference/canvas-editor-tools.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useMeshLocation } from '@uniformdev/mesh-sdk-react'; - -const CanvasEditorTools = () => { - const { value, metadata } = useMeshLocation('canvasEditorTools'); - return ( -
-

{`Raw ${value.entityType} data`}

-
{JSON.stringify(value)}
-

Metadata

-
{JSON.stringify(metadata)}
-
- ); -}; - -export default CanvasEditorTools; diff --git a/mesh/mesh-integration/pages/reference/canvasEditorTools.tsx b/mesh/mesh-integration/pages/reference/canvasEditorTools.tsx new file mode 100644 index 00000000..d6607ea5 --- /dev/null +++ b/mesh/mesh-integration/pages/reference/canvasEditorTools.tsx @@ -0,0 +1,111 @@ +import { + type EditorNode, + type EditorNodeChildren, + type EditorStateApi, + useMeshLocation, +} from '@uniformdev/mesh-sdk-react'; +import { useEffect, useState } from 'react'; + +type SlotViewerProps = { + slotName: string; + childIds: string[]; + editorState: EditorStateApi; +}; + +const SlotViewer = ({ slotName, childIds, editorState }: SlotViewerProps) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
e.target === e.currentTarget && setIsOpen((e.target as HTMLDetailsElement).open)} + style={{ marginLeft: 8 }} + > + + {slotName} (slot) + + {isOpen && childIds.map((id) => )} +
+ ); +}; + +type NodeViewerProps = { + nodeId: string; + editorState: EditorStateApi; +}; + +const NodeViewer = ({ nodeId, editorState }: NodeViewerProps) => { + const [node, setNode] = useState(null); + const [children, setChildren] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + editorState.getNodeById({ nodeId }).then((n) => setNode(n ?? null)); + }, [nodeId, editorState]); + + useEffect(() => { + if (!isOpen) { + setChildren(null); + return; + } + editorState.getNodeChildren({ nodeId }).then((c) => setChildren(c ?? null)); + }, [isOpen, nodeId, editorState]); + + if (!node) return null; + + return ( +
e.target === e.currentTarget && setIsOpen((e.target as HTMLDetailsElement).open)} + style={{ marginLeft: 16 }} + > + + {node.value.type} + + {isOpen && + children && + Object.entries(children).map(([slot, ids]) => ( + + ))} +
+ ); +}; + +const CanvasEditorTools = () => { + const { value, metadata, editorState } = useMeshLocation('canvasEditorTools'); + const [rootId, setRootId] = useState(null); + + useEffect(() => { + editorState.getRootNodeId().then(setRootId); + }, [editorState]); + + return ( +
+

Metadata

+
{JSON.stringify(metadata)}
+ +
+

{value.entityType} data

+ {rootId ? :
Loading root...
} +
+ +

Set the Title property in en-US

+ { + if (!rootId) return; + editorState.updateNodeProperty({ + nodeId: rootId, + property: 'title', + value: e.target.value, + conditionIndex: -1, + locale: 'en-US', + type: 'text', + }); + }} + /> +
+ ); +}; + +export default CanvasEditorTools; diff --git a/mesh/mesh-integration/pages/reference/canvasEditorToolsTranslation.tsx b/mesh/mesh-integration/pages/reference/canvasEditorToolsTranslation.tsx new file mode 100644 index 00000000..b82241b6 --- /dev/null +++ b/mesh/mesh-integration/pages/reference/canvasEditorToolsTranslation.tsx @@ -0,0 +1,398 @@ +import { + type ComponentParameter, + type EntryData, + parseVariableExpression, + type RootComponentInstance, + walkNodeTree, +} from '@uniformdev/canvas'; +import { Button, Callout, InputSelect, VerticalRhythm } from '@uniformdev/design-system'; +import { type EditorStateApi, useMeshLocation } from '@uniformdev/mesh-sdk-react'; +import { useCallback, useState } from 'react'; + +/* + Example of a canvaseditor tool to perform machine translation. + This is a placeholder implementation that simulates a machine translation service. + In a real-world scenario, you would use a machine translation API to translate the values. + This implementation is not production-ready and is only meant as an example. +*/ + +type TranslationProgress = { + nodeType: string; + property: string; + conditionIndex: number; + sourceValue: unknown; + count: number; +}; + +type TranslationStatus = 'idle' | 'translating' | 'done'; + +/** + * Checks if a string is purely a dynamic token bind expression with no static text. + * Uses the canvas parseVariableExpression to detect if the entire value is a single variable reference. + */ +function isPureBindExpression(value: string): boolean { + let hasText = false; + let variableCount = 0; + + parseVariableExpression(value, (_, type) => { + if (type === 'text') { + hasText = true; + } else if (type === 'variable') { + variableCount++; + } + }); + + // It's a pure bind expression if there's exactly one variable and no text tokens + return variableCount === 1 && !hasText; +} + +/** + * Placeholder translate function that prefixes string values with the target locale. + * Includes an artificial 1s delay to simulate real translation. + */ +async function placeholderTranslate(value: unknown, targetLocale: string): Promise { + // Artificial 1s delay to simulate async translation + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (typeof value === 'string') { + if (isPureBindExpression(value)) { + return value; + } + + return `in ${targetLocale} (mocked): ${value}`; + } + + // FUTURE ENHANCEMENT: translate other value types, e.g. rich text, image alt text, etc + + // Skip unknown value types + return value; +} + +/** + * Processes a single property value and translates it if needed. + */ +async function translatePropertyValue( + editorState: EditorStateApi, + nodeId: string, + propertyName: string, + propertyType: string, + sourceValue: unknown, + conditionIndex: number, + hasSourceLocaleValue: boolean, + targetLocale: string +): Promise { + // Only select the component/parameter if it has a value in the source locale + if (hasSourceLocaleValue) { + await editorState.setSelectedNodeId({ nodeId }); + await editorState.setSelectedParameterId({ parameterId: propertyName }); + } + + // Translate the value + const translatedValue = await placeholderTranslate(sourceValue, targetLocale); + + // Update the property with the translated value in the target locale + await editorState.updateNodeProperty({ + nodeId, + property: propertyName, + value: translatedValue, + type: propertyType, + locale: targetLocale, + conditionIndex, + }); +} + +/** + * Processes a single property (parameter or field) and translates all its values. + */ +async function processProperty( + editorState: EditorStateApi, + nodeId: string, + nodeType: string, + propertyName: string, + property: ComponentParameter, + sourceLocale: string | undefined, + targetLocale: string, + onProgress: (progress: TranslationProgress) => void, + countRef: { current: number } +): Promise { + // Skip block parameters (they contain nested components, not translatable values) + if (property.type === 'block') { + return; + } + + // Process base value (from locales or invariant) + let sourceValue: unknown; + let hasSourceLocaleValue = false; + + if (sourceLocale) { + const localeValue = property.locales?.[sourceLocale]; + if (localeValue !== undefined) { + sourceValue = localeValue; + hasSourceLocaleValue = true; + } else { + sourceValue = property.value; + hasSourceLocaleValue = false; + } + } else { + sourceValue = property.value; + hasSourceLocaleValue = false; + } + + // Translate base value if it exists + if (sourceValue !== undefined && sourceValue !== null && sourceValue !== '') { + countRef.current++; + onProgress({ + nodeType, + property: propertyName, + conditionIndex: -1, + sourceValue, + count: countRef.current, + }); + + await translatePropertyValue( + editorState, + nodeId, + propertyName, + property.type, + sourceValue, + -1, + hasSourceLocaleValue, + targetLocale + ); + } + + // Process conditional values (from localesConditions ) + const conditions = property.localesConditions?.[sourceLocale ?? '']; + + if (conditions) { + for (let i = 0; i < conditions.length; i++) { + const condition = conditions[i]; + if (condition.value !== undefined && condition.value !== null && condition.value !== '') { + countRef.current++; + onProgress({ + nodeType, + property: propertyName, + conditionIndex: i, + sourceValue: condition.value, + count: countRef.current, + }); + + await translatePropertyValue( + editorState, + nodeId, + propertyName, + property.type, + condition.value, + i, + sourceLocale !== undefined, + targetLocale + ); + } + } + } +} + +/** + * Walks the tree and translates all values as they are discovered. + */ +async function translateTree( + tree: RootComponentInstance | EntryData, + editorState: EditorStateApi, + sourceLocale: string | undefined, + targetLocale: string, + onProgress: (progress: TranslationProgress) => void +): Promise { + // Check enabled locales from root node + const enabledLocales: string[] = tree._locales ?? []; + + // Only enable the target locale if it's not already enabled + if (!enabledLocales.includes(targetLocale)) { + await editorState.enableLocale({ locale: targetLocale }); + } + + // Switch to the target locale in the editor UI + await editorState.setCurrentLocale({ locale: targetLocale }); + + const countRef = { current: 0 }; + + // Collect all nodes first (walkNodeTree is synchronous) + const nodes: Array<{ + type: 'component' | 'entry'; + nodeId: string; + nodeType: string; + properties: Record; + }> = []; + + walkNodeTree(tree, ({ type, node }) => { + if (type === 'component') { + const nodeId = node._id; + if (nodeId) { + nodes.push({ type: 'component', nodeId, nodeType: node.type, properties: node.parameters ?? {} }); + } + } else if (type === 'entry') { + const nodeId = node._id; + if (nodeId) { + nodes.push({ type: 'entry', nodeId, nodeType: node.type, properties: node.fields ?? {} }); + } + } + }); + + // Process each node's properties + for (const nodeInfo of nodes) { + for (const [propertyName, property] of Object.entries(nodeInfo.properties)) { + await processProperty( + editorState, + nodeInfo.nodeId, + nodeInfo.nodeType, + propertyName, + property, + sourceLocale, + targetLocale, + onProgress, + countRef + ); + } + } + + return countRef.current; +} + +const TranslationTool = () => { + const { metadata, editorState } = useMeshLocation('canvasEditorTools'); + + const [sourceLocale, setSourceLocale] = useState(metadata.currentLocale || ''); + const [targetLocale, setTargetLocale] = useState(''); + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState(null); + const [totalTranslated, setTotalTranslated] = useState(0); + const [error, setError] = useState(null); + + const allLocaleOptions = [ + { label: 'Select one...', value: '' }, + ...metadata.locales.map((l) => ({ label: l.displayName, value: l.locale })), + ]; + + const canTranslate = targetLocale && sourceLocale !== targetLocale; + + const handleTranslate = useCallback(async () => { + if (!canTranslate) { + return; + } + + setStatus('translating'); + setError(null); + setProgress(null); + setTotalTranslated(0); + + try { + const tree = await editorState.exportTree(); + const count = await translateTree( + tree, + editorState, + sourceLocale || undefined, + targetLocale, + setProgress + ); + setTotalTranslated(count); + setProgress(null); + setStatus('done'); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + setError(e instanceof Error ? e.message : 'Translation failed'); + setStatus('idle'); + } + }, [editorState, sourceLocale, targetLocale, canTranslate]); + + const handleReset = useCallback(() => { + setStatus('idle'); + setProgress(null); + setTotalTranslated(0); + setError(null); + }, []); + + const sourceLocaleDisplay = sourceLocale + ? metadata.locales.find((l) => l.locale === sourceLocale)?.displayName || sourceLocale + : '(invariant)'; + const targetLocaleDisplay = targetLocale + ? metadata.locales.find((l) => l.locale === targetLocale)?.displayName || targetLocale + : ''; + + return ( + + {error &&
{error}
} + + {status === 'idle' && ( + + setSourceLocale(e.target.value)} + options={allLocaleOptions} + /> + + setTargetLocale(e.target.value)} + options={allLocaleOptions} + /> + + + + )} + + {status === 'translating' && ( + +
+ Translating from {sourceLocaleDisplay} to {targetLocaleDisplay} +
+ +
Translated: {progress?.count ?? 0} values
+ + {progress && ( +
+ +
+ Translating: +
+
+ Node: {progress.nodeType} +
+
+ Property: {progress.property} + {progress.conditionIndex >= 0 && [condition {progress.conditionIndex}]} +
+
+ {typeof progress.sourceValue === 'string' + ? `"${progress.sourceValue.substring(0, 100)}${progress.sourceValue.length > 100 ? '...' : ''}"` + : `(${typeof progress.sourceValue})`} +
+
+
+ )} +
+ )} + + {status === 'done' && ( + + + Translated {totalTranslated} values to {targetLocaleDisplay}. + + + + + )} +
+ ); +}; + +export default TranslationTool; diff --git a/mesh/mesh-integration/pages/reference/dataResourceSelector.tsx b/mesh/mesh-integration/pages/reference/dataResourceSelector.tsx new file mode 100644 index 00000000..a4d96523 --- /dev/null +++ b/mesh/mesh-integration/pages/reference/dataResourceSelector.tsx @@ -0,0 +1,85 @@ +import { VerticalRhythm } from '@uniformdev/design-system'; +import { Callout, useMeshLocation } from '@uniformdev/mesh-sdk-react'; +import type { NextPage } from 'next'; + +/** + * Data Resource Selector demonstration + * + * This location replaces the default JSON tree viewer when selecting dynamic tokens + * from data of an archetype that has dataResourceSelectorUrl configured. + * + * Available metadata: + * - dataResourceValue: The resolved data for the data resource + * - dataResourceName: The name of the data resource + * - dataTypeId: The data type ID (for fetching additional context if needed) + * - archetype: The archetype key (useful for apps sharing a selector across archetypes) + * - allowedTypes: Array of bindable types valid for selection + * - componentDefinitions: Component definitions index, keyed by public id + * + * Available location API: + * - editorState: Imperative API for inspecting/mutating the composition/entry tree (use for component/root context) + * + * The mesh app can call setValue with a JSON pointer, and getDataResource to fetch more data. + */ + +const DataResourceSelector: NextPage = () => { + const location = useMeshLocation('dataResourceSelector'); + const { value, setValue, metadata, isReadOnly } = location; + const { dataResourceValue } = metadata; + + if (!dataResourceValue || typeof dataResourceValue !== 'object') { + return No data available for this data resource.; + } + + // Get top-level keys from the data resource + const keys = Object.keys(dataResourceValue); + + const handleSelect = (valueToSelect: string) => { + if (isReadOnly) { + return; + } + // Set the JSON pointer for the selected key + setValue(() => { + return { + newValue: valueToSelect, + }; + }); + }; + + return ( + + Select a top-level array: + + + {keys.map((key) => { + const jsonPointerToSelect = `/${key}`; + const isSelected = value === jsonPointerToSelect; + const targetValue = (dataResourceValue as Record)[key]; + + if (!Array.isArray(targetValue)) { + return null; + } + + return ( + + ); + })} + + + {value ? ( +
+ Current selection: {value} +
+ ) : null} +
+ ); +}; + +export default DataResourceSelector;