New data query node for Directus backend integration

This commit is contained in:
Richard Osborne
2025-12-30 11:55:30 +01:00
parent 6fd59e83e6
commit ae7d3b8a8b
52 changed files with 17798 additions and 303 deletions

View File

@@ -0,0 +1,546 @@
# CONFIG-001: Core Infrastructure
## Overview
Implement the foundational `Noodl.Config` namespace, data model, project storage, and runtime initialization.
**Estimated effort:** 14-18 hours
**Dependencies:** None
**Blocks:** All other CONFIG subtasks
---
## Objectives
1. Create `Noodl.Config` as an immutable runtime namespace
2. Define TypeScript interfaces for configuration data
3. Implement storage in project.json metadata
4. Initialize config values at app startup
5. Establish default values for required fields
---
## Files to Create
### 1. Type Definitions
**File:** `packages/noodl-runtime/src/config/types.ts`
```typescript
export type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object';
export interface ConfigVariable {
key: string;
type: ConfigType;
value: any;
description?: string;
category?: string;
validation?: ConfigValidation;
}
export interface ConfigValidation {
required?: boolean;
pattern?: string;
min?: number;
max?: number;
}
export interface AppIdentity {
appName: string;
description: string;
coverImage?: string;
}
export interface AppSEO {
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
favicon?: string;
themeColor?: string;
}
export interface AppPWA {
enabled: boolean;
shortName?: string;
startUrl: string;
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
backgroundColor?: string;
sourceIcon?: string;
}
export interface AppConfig {
identity: AppIdentity;
seo: AppSEO;
pwa?: AppPWA;
variables: ConfigVariable[];
}
export const DEFAULT_APP_CONFIG: AppConfig = {
identity: {
appName: 'My Noodl App',
description: ''
},
seo: {},
variables: []
};
```
### 2. Config Manager (Runtime)
**File:** `packages/noodl-runtime/src/config/config-manager.ts`
```typescript
import { AppConfig, ConfigVariable, DEFAULT_APP_CONFIG } from './types';
class ConfigManager {
private static instance: ConfigManager;
private config: AppConfig = DEFAULT_APP_CONFIG;
private frozenConfig: Readonly<Record<string, any>> | null = null;
static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
/**
* Initialize config from project metadata.
* Called once at app startup.
*/
initialize(config: Partial<AppConfig>): void {
this.config = {
...DEFAULT_APP_CONFIG,
...config,
identity: {
...DEFAULT_APP_CONFIG.identity,
...config.identity
},
seo: {
...DEFAULT_APP_CONFIG.seo,
...config.seo
}
};
// Build the frozen public config object
this.frozenConfig = this.buildFrozenConfig();
}
/**
* Get the immutable Noodl.Config object.
*/
getConfig(): Readonly<Record<string, any>> {
if (!this.frozenConfig) {
this.frozenConfig = this.buildFrozenConfig();
}
return this.frozenConfig;
}
/**
* Get raw config for editor use.
*/
getRawConfig(): AppConfig {
return this.config;
}
/**
* Get a specific variable definition.
*/
getVariable(key: string): ConfigVariable | undefined {
return this.config.variables.find(v => v.key === key);
}
/**
* Get all variable keys for autocomplete.
*/
getVariableKeys(): string[] {
return this.config.variables.map(v => v.key);
}
private buildFrozenConfig(): Readonly<Record<string, any>> {
const config: Record<string, any> = {
// Identity fields
appName: this.config.identity.appName,
description: this.config.identity.description,
coverImage: this.config.identity.coverImage,
// SEO fields (with defaults)
ogTitle: this.config.seo.ogTitle || this.config.identity.appName,
ogDescription: this.config.seo.ogDescription || this.config.identity.description,
ogImage: this.config.seo.ogImage || this.config.identity.coverImage,
favicon: this.config.seo.favicon,
themeColor: this.config.seo.themeColor,
// PWA fields
pwaEnabled: this.config.pwa?.enabled ?? false,
pwaShortName: this.config.pwa?.shortName,
pwaDisplay: this.config.pwa?.display,
pwaStartUrl: this.config.pwa?.startUrl,
pwaBackgroundColor: this.config.pwa?.backgroundColor
};
// Add custom variables
for (const variable of this.config.variables) {
config[variable.key] = variable.value;
}
// Freeze to prevent mutation
return Object.freeze(config);
}
}
export const configManager = ConfigManager.getInstance();
```
### 3. Noodl.Config API
**File:** `packages/noodl-viewer-react/src/config-api.ts`
```typescript
import { configManager } from '@noodl/runtime/src/config/config-manager';
/**
* Create the Noodl.Config proxy object.
* Returns an immutable object that throws on write attempts.
*/
export function createConfigAPI(): Readonly<Record<string, any>> {
const config = configManager.getConfig();
return new Proxy(config, {
get(target, prop: string) {
if (prop in target) {
return target[prop];
}
console.warn(`Noodl.Config.${prop} is not defined`);
return undefined;
},
set(target, prop: string, value) {
console.error(
`Cannot set Noodl.Config.${prop} - Config values are immutable. ` +
`Use Noodl.Variables for runtime-changeable values.`
);
return false;
},
deleteProperty(target, prop: string) {
console.error(`Cannot delete Noodl.Config.${prop} - Config values are immutable.`);
return false;
}
});
}
```
### 4. Update Noodl JS API
**File:** `packages/noodl-viewer-react/src/noodl-js-api.js` (modify)
Add Config to the Noodl namespace:
```javascript
// Add import
import { createConfigAPI } from './config-api';
// In the Noodl object initialization
const Noodl = {
// ... existing properties
Config: createConfigAPI(),
// ...
};
```
---
## Files to Modify
### 1. Project Model
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
Add methods for app config:
```typescript
// Add to ProjectModel class
getAppConfig(): AppConfig {
return this.getMetaData('appConfig') || DEFAULT_APP_CONFIG;
}
setAppConfig(config: AppConfig): void {
this.setMetaData('appConfig', config);
}
updateAppConfig(updates: Partial<AppConfig>): void {
const current = this.getAppConfig();
this.setAppConfig({
...current,
...updates
});
}
// Config variables helpers
getConfigVariables(): ConfigVariable[] {
return this.getAppConfig().variables;
}
setConfigVariable(variable: ConfigVariable): void {
const config = this.getAppConfig();
const index = config.variables.findIndex(v => v.key === variable.key);
if (index >= 0) {
config.variables[index] = variable;
} else {
config.variables.push(variable);
}
this.setAppConfig(config);
}
removeConfigVariable(key: string): void {
const config = this.getAppConfig();
config.variables = config.variables.filter(v => v.key !== key);
this.setAppConfig(config);
}
```
### 2. Runtime Initialization
**File:** `packages/noodl-viewer-react/src/index.js` (or equivalent entry)
Initialize config from project data:
```javascript
// During app initialization
import { configManager } from '@noodl/runtime/src/config/config-manager';
// After project data is loaded
const appConfig = projectData.metadata?.appConfig;
if (appConfig) {
configManager.initialize(appConfig);
}
```
### 3. Type Declarations
**File:** `packages/noodl-viewer-react/static/viewer/global.d.ts.keep`
Add Config type declarations:
```typescript
declare namespace Noodl {
// ... existing declarations
/**
* App configuration values defined in App Setup.
* These values are static and cannot be changed at runtime.
*
* @example
* // Access a config value
* const color = Noodl.Config.primaryColor;
*
* // This will throw an error:
* Noodl.Config.primaryColor = "#000"; // ❌ Cannot modify
*/
const Config: Readonly<{
// Identity
appName: string;
description: string;
coverImage?: string;
// SEO
ogTitle: string;
ogDescription: string;
ogImage?: string;
favicon?: string;
themeColor?: string;
// PWA
pwaEnabled: boolean;
pwaShortName?: string;
pwaDisplay?: string;
pwaStartUrl?: string;
pwaBackgroundColor?: string;
// Custom variables (dynamic)
[key: string]: any;
}>;
}
```
---
## Validation Logic
**File:** `packages/noodl-runtime/src/config/validation.ts`
```typescript
import { ConfigVariable, ConfigValidation } from './types';
export interface ValidationResult {
valid: boolean;
errors: string[];
}
export function validateConfigKey(key: string): ValidationResult {
const errors: string[] = [];
// Must be valid JS identifier
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
errors.push('Key must be a valid JavaScript identifier');
}
// Reserved keys
const reserved = [
'appName', 'description', 'coverImage',
'ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor',
'pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor'
];
if (reserved.includes(key)) {
errors.push(`"${key}" is a reserved configuration key`);
}
return { valid: errors.length === 0, errors };
}
export function validateConfigValue(
value: any,
type: string,
validation?: ConfigValidation
): ValidationResult {
const errors: string[] = [];
// Required check
if (validation?.required && (value === undefined || value === null || value === '')) {
errors.push('This field is required');
return { valid: false, errors };
}
// Type-specific validation
switch (type) {
case 'number':
if (typeof value !== 'number' || isNaN(value)) {
errors.push('Value must be a number');
} else {
if (validation?.min !== undefined && value < validation.min) {
errors.push(`Value must be at least ${validation.min}`);
}
if (validation?.max !== undefined && value > validation.max) {
errors.push(`Value must be at most ${validation.max}`);
}
}
break;
case 'string':
if (typeof value !== 'string') {
errors.push('Value must be a string');
} else if (validation?.pattern) {
const regex = new RegExp(validation.pattern);
if (!regex.test(value)) {
errors.push('Value does not match required pattern');
}
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
errors.push('Value must be true or false');
}
break;
case 'color':
if (typeof value !== 'string' || !/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)) {
errors.push('Value must be a valid hex color (e.g., #ff0000)');
}
break;
case 'array':
if (!Array.isArray(value)) {
errors.push('Value must be an array');
}
break;
case 'object':
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
errors.push('Value must be an object');
}
break;
}
return { valid: errors.length === 0, errors };
}
```
---
## Testing Checklist
### Unit Tests
- [ ] ConfigManager initializes with defaults
- [ ] ConfigManager merges provided config with defaults
- [ ] Frozen config includes all identity fields
- [ ] Frozen config includes all SEO fields with defaults
- [ ] Frozen config includes all custom variables
- [ ] Config object is truly immutable (Object.isFrozen)
- [ ] Proxy prevents writes and logs error
- [ ] Validation rejects invalid keys
- [ ] Validation rejects reserved keys
- [ ] Type validation works for all types
### Integration Tests
- [ ] ProjectModel saves config to metadata
- [ ] ProjectModel loads config from metadata
- [ ] Runtime initializes config on app load
- [ ] Noodl.Config accessible in Function nodes
- [ ] Config values correct in running app
---
## Implementation Order
1. Create type definitions
2. Create ConfigManager
3. Create validation utilities
4. Update ProjectModel with config methods
5. Create Noodl.Config API proxy
6. Add to Noodl namespace
7. Update type declarations
8. Add runtime initialization
9. Write tests
---
## Notes for Implementer
### Freezing Behavior
The config object must be deeply frozen to prevent any mutation:
```typescript
function deepFreeze<T extends object>(obj: T): Readonly<T> {
Object.keys(obj).forEach(key => {
const value = (obj as any)[key];
if (value && typeof value === 'object') {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
```
### Error Messages
When users try to modify config values, provide helpful error messages that guide them to use Variables instead:
```javascript
console.error(
`Cannot set Noodl.Config.${prop} - Config values are immutable. ` +
`Use Noodl.Variables for runtime-changeable values.`
);
```
### Cloud Function Context
Ensure ConfigManager works in both browser and cloud function contexts. The cloud functions have a separate noodl-js-api that will need similar updates.

View File

@@ -0,0 +1,944 @@
# CONFIG-002: App Setup Panel UI
## Overview
Create a new "App Setup" top-level sidebar panel for editing app configuration, SEO metadata, PWA settings, and custom variables.
**Estimated effort:** 18-24 hours
**Dependencies:** CONFIG-001
**Blocks:** CONFIG-004, CONFIG-005
---
## Objectives
1. Add new "App Setup" tab to sidebar navigation
2. Create panel sections for Identity, SEO, PWA, and Custom Variables
3. Integrate existing port type editors for type-aware editing
4. Implement add/edit/remove flows for custom variables
5. Support category grouping for variables
6. Migrate relevant settings from Project Settings panel
---
## Files to Create
### 1. App Setup Panel
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/AppSetupPanel.tsx`
```tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
import { AppConfig } from '@noodl/runtime/src/config/types';
import { IdentitySection } from './sections/IdentitySection';
import { SEOSection } from './sections/SEOSection';
import { PWASection } from './sections/PWASection';
import { VariablesSection } from './sections/VariablesSection';
export function AppSetupPanel() {
const [config, setConfig] = useState<AppConfig>(
ProjectModel.instance.getAppConfig()
);
const updateConfig = useCallback((updates: Partial<AppConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
ProjectModel.instance.setAppConfig(newConfig);
}, [config]);
const updateIdentity = useCallback((identity: Partial<AppConfig['identity']>) => {
updateConfig({
identity: { ...config.identity, ...identity }
});
}, [config, updateConfig]);
const updateSEO = useCallback((seo: Partial<AppConfig['seo']>) => {
updateConfig({
seo: { ...config.seo, ...seo }
});
}, [config, updateConfig]);
const updatePWA = useCallback((pwa: Partial<AppConfig['pwa']>) => {
updateConfig({
pwa: { ...config.pwa, ...pwa } as AppConfig['pwa']
});
}, [config, updateConfig]);
return (
<BasePanel title="App Setup" hasContentScroll>
<IdentitySection
identity={config.identity}
onChange={updateIdentity}
/>
<SEOSection
seo={config.seo}
identity={config.identity}
onChange={updateSEO}
/>
<PWASection
pwa={config.pwa}
onChange={updatePWA}
/>
<VariablesSection
variables={config.variables}
onChange={(variables) => updateConfig({ variables })}
/>
</BasePanel>
);
}
```
### 2. Identity Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/IdentitySection.tsx`
```tsx
import React from 'react';
import { AppIdentity } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelTextArea } from '@noodl-core-ui/components/property-panel/PropertyPanelTextArea';
import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker';
interface IdentitySectionProps {
identity: AppIdentity;
onChange: (updates: Partial<AppIdentity>) => void;
}
export function IdentitySection({ identity, onChange }: IdentitySectionProps) {
return (
<CollapsableSection title="App Identity" hasGutter hasVisibleOverflow>
<PropertyPanelRow label="App Name">
<PropertyPanelTextInput
value={identity.appName}
onChange={(value) => onChange({ appName: value })}
placeholder="My Noodl App"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Description">
<PropertyPanelTextArea
value={identity.description}
onChange={(value) => onChange({ description: value })}
placeholder="Describe your app..."
rows={3}
/>
</PropertyPanelRow>
<PropertyPanelRow label="Cover Image">
<ImagePicker
value={identity.coverImage}
onChange={(value) => onChange({ coverImage: value })}
placeholder="Select cover image..."
/>
</PropertyPanelRow>
</CollapsableSection>
);
}
```
### 3. SEO Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/SEOSection.tsx`
```tsx
import React from 'react';
import { AppSEO, AppIdentity } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker';
import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { Box } from '@noodl-core-ui/components/layout/Box';
interface SEOSectionProps {
seo: AppSEO;
identity: AppIdentity;
onChange: (updates: Partial<AppSEO>) => void;
}
export function SEOSection({ seo, identity, onChange }: SEOSectionProps) {
return (
<CollapsableSection title="SEO & Metadata" hasGutter hasVisibleOverflow>
<PropertyPanelRow label="OG Title">
<PropertyPanelTextInput
value={seo.ogTitle || ''}
onChange={(value) => onChange({ ogTitle: value || undefined })}
placeholder={identity.appName || 'Defaults to App Name'}
/>
</PropertyPanelRow>
<Box hasBottomSpacing>
<Text textType="shy">Defaults to App Name if empty</Text>
</Box>
<PropertyPanelRow label="OG Description">
<PropertyPanelTextInput
value={seo.ogDescription || ''}
onChange={(value) => onChange({ ogDescription: value || undefined })}
placeholder={identity.description ? 'Defaults to Description' : 'Enter description...'}
/>
</PropertyPanelRow>
<Box hasBottomSpacing>
<Text textType="shy">Defaults to Description if empty</Text>
</Box>
<PropertyPanelRow label="OG Image">
<ImagePicker
value={seo.ogImage}
onChange={(value) => onChange({ ogImage: value })}
placeholder="Defaults to Cover Image"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Favicon">
<ImagePicker
value={seo.favicon}
onChange={(value) => onChange({ favicon: value })}
placeholder="Select favicon..."
accept=".ico,.png,.svg"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Theme Color">
<PropertyPanelColorPicker
value={seo.themeColor}
onChange={(value) => onChange({ themeColor: value })}
/>
</PropertyPanelRow>
</CollapsableSection>
);
}
```
### 4. PWA Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/PWASection.tsx`
```tsx
import React from 'react';
import { AppPWA } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker';
import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { Box } from '@noodl-core-ui/components/layout/Box';
interface PWASectionProps {
pwa?: AppPWA;
onChange: (updates: Partial<AppPWA>) => void;
}
const DISPLAY_OPTIONS = [
{ value: 'standalone', label: 'Standalone' },
{ value: 'fullscreen', label: 'Fullscreen' },
{ value: 'minimal-ui', label: 'Minimal UI' },
{ value: 'browser', label: 'Browser' }
];
export function PWASection({ pwa, onChange }: PWASectionProps) {
const enabled = pwa?.enabled ?? false;
return (
<CollapsableSection
title="PWA Configuration"
hasGutter
hasVisibleOverflow
hasTopDivider
isClosed={!enabled}
headerContent={
<PropertyPanelCheckbox
value={enabled}
onChange={(value) => onChange({ enabled: value })}
/>
}
>
{enabled && (
<>
<PropertyPanelRow label="Short Name">
<PropertyPanelTextInput
value={pwa?.shortName || ''}
onChange={(value) => onChange({ shortName: value })}
placeholder="Short app name for home screen"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Display Mode">
<PropertyPanelSelectInput
value={pwa?.display || 'standalone'}
options={DISPLAY_OPTIONS}
onChange={(value) => onChange({ display: value as AppPWA['display'] })}
/>
</PropertyPanelRow>
<PropertyPanelRow label="Start URL">
<PropertyPanelTextInput
value={pwa?.startUrl || '/'}
onChange={(value) => onChange({ startUrl: value || '/' })}
placeholder="/"
/>
</PropertyPanelRow>
<PropertyPanelRow label="Background Color">
<PropertyPanelColorPicker
value={pwa?.backgroundColor}
onChange={(value) => onChange({ backgroundColor: value })}
/>
</PropertyPanelRow>
<PropertyPanelRow label="App Icon">
<ImagePicker
value={pwa?.sourceIcon}
onChange={(value) => onChange({ sourceIcon: value })}
placeholder="512x512 PNG recommended"
accept=".png"
/>
</PropertyPanelRow>
<Box hasBottomSpacing>
<Text textType="shy">
Provide a 512x512 PNG. Smaller sizes will be generated automatically.
</Text>
</Box>
</>
)}
</CollapsableSection>
);
}
```
### 5. Variables Section
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariablesSection.tsx`
```tsx
import React, { useState, useMemo } from 'react';
import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { VariableGroup } from './VariableGroup';
import { AddVariableDialog } from '../dialogs/AddVariableDialog';
interface VariablesSectionProps {
variables: ConfigVariable[];
onChange: (variables: ConfigVariable[]) => void;
}
export function VariablesSection({ variables, onChange }: VariablesSectionProps) {
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingVariable, setEditingVariable] = useState<ConfigVariable | null>(null);
// Group variables by category
const groupedVariables = useMemo(() => {
const groups: Record<string, ConfigVariable[]> = {};
for (const variable of variables) {
const category = variable.category || 'Custom';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(variable);
}
return groups;
}, [variables]);
const handleAddVariable = (variable: ConfigVariable) => {
onChange([...variables, variable]);
setShowAddDialog(false);
};
const handleUpdateVariable = (key: string, updates: Partial<ConfigVariable>) => {
onChange(variables.map(v =>
v.key === key ? { ...v, ...updates } : v
));
};
const handleDeleteVariable = (key: string) => {
onChange(variables.filter(v => v.key !== key));
};
const handleEditVariable = (variable: ConfigVariable) => {
setEditingVariable(variable);
setShowAddDialog(true);
};
const handleSaveEdit = (variable: ConfigVariable) => {
if (editingVariable) {
// If key changed, remove old and add new
if (editingVariable.key !== variable.key) {
onChange([
...variables.filter(v => v.key !== editingVariable.key),
variable
]);
} else {
handleUpdateVariable(variable.key, variable);
}
}
setEditingVariable(null);
setShowAddDialog(false);
};
return (
<CollapsableSection
title="Configuration Variables"
hasGutter
hasVisibleOverflow
hasTopDivider
headerContent={
<PrimaryButton
icon={IconName.Plus}
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Ghost}
onClick={() => setShowAddDialog(true)}
label="Add"
/>
}
>
{Object.entries(groupedVariables).map(([category, vars]) => (
<VariableGroup
key={category}
category={category}
variables={vars}
onUpdate={handleUpdateVariable}
onDelete={handleDeleteVariable}
onEdit={handleEditVariable}
/>
))}
{variables.length === 0 && (
<Text textType="shy">
No custom variables defined. Click "Add" to create one.
</Text>
)}
{showAddDialog && (
<AddVariableDialog
existingKeys={variables.map(v => v.key)}
editingVariable={editingVariable}
onSave={editingVariable ? handleSaveEdit : handleAddVariable}
onCancel={() => {
setShowAddDialog(false);
setEditingVariable(null);
}}
/>
)}
</CollapsableSection>
);
}
```
### 6. Variable Group Component
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableGroup.tsx`
```tsx
import React from 'react';
import { ConfigVariable } from '@noodl/runtime/src/config/types';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { VariableRow } from './VariableRow';
import css from './VariableGroup.module.scss';
interface VariableGroupProps {
category: string;
variables: ConfigVariable[];
onUpdate: (key: string, updates: Partial<ConfigVariable>) => void;
onDelete: (key: string) => void;
onEdit: (variable: ConfigVariable) => void;
}
export function VariableGroup({
category,
variables,
onUpdate,
onDelete,
onEdit
}: VariableGroupProps) {
return (
<Box className={css.Root}>
<Text className={css.CategoryLabel}>{category}</Text>
<div className={css.VariablesList}>
{variables.map(variable => (
<VariableRow
key={variable.key}
variable={variable}
onUpdate={(updates) => onUpdate(variable.key, updates)}
onDelete={() => onDelete(variable.key)}
onEdit={() => onEdit(variable)}
/>
))}
</div>
</Box>
);
}
```
### 7. Variable Row Component
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableRow.tsx`
```tsx
import React from 'react';
import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types';
import { IconName, Icon } from '@noodl-core-ui/components/common/Icon';
import { MenuDialog, MenuDialogItem } from '@noodl-core-ui/components/popups/MenuDialog';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { TypeEditor } from './TypeEditor';
import css from './VariableRow.module.scss';
interface VariableRowProps {
variable: ConfigVariable;
onUpdate: (updates: Partial<ConfigVariable>) => void;
onDelete: () => void;
onEdit: () => void;
}
const TYPE_LABELS: Record<ConfigType, string> = {
string: 'String',
number: 'Number',
boolean: 'Boolean',
color: 'Color',
array: 'Array',
object: 'Object'
};
export function VariableRow({ variable, onUpdate, onDelete, onEdit }: VariableRowProps) {
const menuItems: MenuDialogItem[] = [
{ label: 'Edit', icon: IconName.Pencil, onClick: onEdit },
{ label: 'Delete', icon: IconName.Trash, isDangerousAction: true, onClick: onDelete }
];
return (
<div className={css.Root}>
<div className={css.KeyColumn}>
<Tooltip content={variable.description || `Type: ${TYPE_LABELS[variable.type]}`}>
<span className={css.Key}>{variable.key}</span>
</Tooltip>
<span className={css.Type}>{variable.type}</span>
</div>
<div className={css.ValueColumn}>
<TypeEditor
type={variable.type}
value={variable.value}
onChange={(value) => onUpdate({ value })}
/>
</div>
<MenuDialog items={menuItems}>
<button className={css.MenuButton}>
<Icon icon={IconName.MoreVertical} />
</button>
</MenuDialog>
</div>
);
}
```
### 8. Type Editor Component
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/TypeEditor.tsx`
```tsx
import React from 'react';
import { ConfigType } from '@noodl/runtime/src/config/types';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelNumberInput } from '@noodl-core-ui/components/property-panel/PropertyPanelNumberInput';
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker';
import { PropertyPanelButton } from '@noodl-core-ui/components/property-panel/PropertyPanelButton';
interface TypeEditorProps {
type: ConfigType;
value: any;
onChange: (value: any) => void;
}
export function TypeEditor({ type, value, onChange }: TypeEditorProps) {
switch (type) {
case 'string':
return (
<PropertyPanelTextInput
value={value || ''}
onChange={onChange}
/>
);
case 'number':
return (
<PropertyPanelNumberInput
value={value ?? 0}
onChange={onChange}
/>
);
case 'boolean':
return (
<PropertyPanelCheckbox
value={value ?? false}
onChange={onChange}
/>
);
case 'color':
return (
<PropertyPanelColorPicker
value={value}
onChange={onChange}
/>
);
case 'array':
return (
<PropertyPanelButton
label="Edit Array..."
onClick={() => {
// Open array editor popup
// Reuse existing array editor from port types
}}
/>
);
case 'object':
return (
<PropertyPanelButton
label="Edit Object..."
onClick={() => {
// Open object editor popup
// Reuse existing object editor from port types
}}
/>
);
default:
return <span>Unknown type</span>;
}
}
```
### 9. Add Variable Dialog
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/dialogs/AddVariableDialog.tsx`
```tsx
import React, { useState } from 'react';
import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types';
import { validateConfigKey } from '@noodl/runtime/src/config/validation';
import { DialogRender } from '@noodl-core-ui/components/layout/DialogRender';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import { Text } from '@noodl-core-ui/components/typography/Text';
interface AddVariableDialogProps {
existingKeys: string[];
editingVariable?: ConfigVariable | null;
onSave: (variable: ConfigVariable) => void;
onCancel: () => void;
}
const TYPE_OPTIONS = [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'color', label: 'Color' },
{ value: 'array', label: 'Array' },
{ value: 'object', label: 'Object' }
];
export function AddVariableDialog({
existingKeys,
editingVariable,
onSave,
onCancel
}: AddVariableDialogProps) {
const [key, setKey] = useState(editingVariable?.key || '');
const [type, setType] = useState<ConfigType>(editingVariable?.type || 'string');
const [description, setDescription] = useState(editingVariable?.description || '');
const [category, setCategory] = useState(editingVariable?.category || '');
const [error, setError] = useState<string | null>(null);
const isEditing = !!editingVariable;
const handleSave = () => {
// Validate key
const validation = validateConfigKey(key);
if (!validation.valid) {
setError(validation.errors[0]);
return;
}
// Check for duplicates (unless editing same key)
if (!isEditing || key !== editingVariable?.key) {
if (existingKeys.includes(key)) {
setError('A variable with this key already exists');
return;
}
}
const variable: ConfigVariable = {
key,
type,
value: editingVariable?.value ?? getDefaultValue(type),
description: description || undefined,
category: category || undefined
};
onSave(variable);
};
return (
<DialogRender
title={isEditing ? 'Edit Variable' : 'Add Variable'}
onClose={onCancel}
footer={
<>
<PrimaryButton label="Cancel" variant="ghost" onClick={onCancel} />
<PrimaryButton label={isEditing ? 'Save' : 'Add'} onClick={handleSave} />
</>
}
>
<PropertyPanelRow label="Key">
<PropertyPanelTextInput
value={key}
onChange={(v) => { setKey(v); setError(null); }}
placeholder="myVariable"
hasError={!!error}
/>
</PropertyPanelRow>
{error && <Text textType="danger">{error}</Text>}
<PropertyPanelRow label="Type">
<PropertyPanelSelectInput
value={type}
options={TYPE_OPTIONS}
onChange={(v) => setType(v as ConfigType)}
isDisabled={isEditing} // Can't change type when editing
/>
</PropertyPanelRow>
<PropertyPanelRow label="Description">
<PropertyPanelTextInput
value={description}
onChange={setDescription}
placeholder="Optional description..."
/>
</PropertyPanelRow>
<PropertyPanelRow label="Category">
<PropertyPanelTextInput
value={category}
onChange={setCategory}
placeholder="Custom"
/>
</PropertyPanelRow>
</DialogRender>
);
}
function getDefaultValue(type: ConfigType): any {
switch (type) {
case 'string': return '';
case 'number': return 0;
case 'boolean': return false;
case 'color': return '#000000';
case 'array': return [];
case 'object': return {};
}
}
```
---
## Files to Modify
### 1. Sidebar Navigation
**File:** `packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx`
Add App Setup tab to sidebar:
```tsx
// Add to sidebar tabs
{
id: 'app-setup',
label: 'App Setup',
icon: IconName.Settings, // or appropriate icon
panel: AppSetupPanel
}
```
### 2. Migrate Project Settings
**File:** `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/`
Move the following to App Setup:
- Project name (becomes App Name in Identity)
- Consider migrating other relevant settings
---
## Styles
### Variable Group Styles
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableGroup.module.scss`
```scss
.Root {
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
padding: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.CategoryLabel {
font-size: var(--text-label-size);
font-weight: var(--text-label-weight);
letter-spacing: var(--text-label-letter-spacing);
color: var(--theme-color-fg-muted);
text-transform: uppercase;
margin-bottom: var(--spacing-2);
}
.VariablesList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
```
### Variable Row Styles
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableRow.module.scss`
```scss
.Root {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-1) 0;
}
.KeyColumn {
flex: 0 0 120px;
display: flex;
flex-direction: column;
}
.Key {
font-weight: var(--font-weight-medium);
color: var(--theme-color-fg-default);
}
.Type {
font-size: var(--font-size-xs);
color: var(--theme-color-fg-muted);
}
.ValueColumn {
flex: 1;
min-width: 0;
}
.MenuButton {
flex: 0 0 24px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-default);
color: var(--theme-color-fg-muted);
cursor: pointer;
&:hover {
background-color: var(--theme-color-bg-hover);
color: var(--theme-color-fg-default);
}
}
```
---
## Testing Checklist
### Functional Tests
- [ ] App Setup panel appears in sidebar
- [ ] Can edit App Name
- [ ] Can edit Description (multiline)
- [ ] Can upload/select Cover Image
- [ ] SEO fields show defaults when empty
- [ ] Can override SEO fields
- [ ] Can enable/disable PWA section
- [ ] PWA fields editable when enabled
- [ ] Can add new variable
- [ ] Can edit existing variable
- [ ] Can delete variable
- [ ] Type editor matches variable type
- [ ] Color picker works
- [ ] Array editor opens
- [ ] Object editor opens
- [ ] Categories group correctly
- [ ] Uncategorized → "Custom"
- [ ] Validation prevents duplicate keys
- [ ] Validation prevents reserved keys
- [ ] Validation prevents invalid key names
### Visual Tests
- [ ] Panel matches design mockup
- [ ] Sections collapsible
- [ ] Proper spacing and alignment
- [ ] Dark theme compatible
- [ ] Responsive to panel width
---
## Notes for Implementer
### Reusing Port Type Editors
The existing port type editors in `views/panels/propertyeditor/DataTypes/Ports.ts` handle most input types. Consider extracting shared components or directly importing the editor logic for consistency.
### Image Picker
Create a shared ImagePicker component that can:
- Browse project files
- Upload new images
- Accept URL input
- Show preview thumbnail
### Array/Object Editors
Reuse the existing popup editors for array and object types. These are used in the Static Array node and Function node.

View File

@@ -0,0 +1,522 @@
# CONFIG-003: App Config Node
## Overview
Create an "App Config" node that provides selected configuration values as outputs, for users who prefer visual programming over expressions.
**Estimated effort:** 10-14 hours
**Dependencies:** CONFIG-001
**Blocks:** None
---
## Objectives
1. Create App Config node with selectable variable outputs
2. Preserve output types (color outputs as color, etc.)
3. Integrate with node picker
4. Rename existing "Config" node to "Noodl Cloud Config"
5. Hide cloud data nodes when no backend connected
---
## Node Design
### App Config Node
```
┌──────────────────────────────┐
│ App Config │
├──────────────────────────────┤
│ Variables │
│ ┌──────────────────────────┐ │
│ │ ☑ appName │ │
│ │ ☑ primaryColor │ │
│ │ ☐ description │ │
│ │ ☑ apiBaseUrl │ │
│ │ ☑ menuItems │ │
│ └──────────────────────────┘ │
├──────────────────────────────┤
│ ○ appName │──→ string
│ ○ primaryColor │──→ color
│ ○ apiBaseUrl │──→ string
│ ○ menuItems │──→ array
└──────────────────────────────┘
```
---
## Files to Create
### 1. App Config Node
**File:** `packages/noodl-runtime/src/nodes/std-library/data/appconfignode.js`
```javascript
'use strict';
const { configManager } = require('../../../config/config-manager');
const AppConfigNode = {
name: 'App Config',
displayName: 'App Config',
docs: 'https://docs.noodl.net/nodes/data/app-config',
shortDesc: 'Access app configuration values defined in App Setup.',
category: 'Variables',
color: 'data',
initialize: function() {
this._internal.outputValues = {};
},
getInspectInfo() {
const selected = this._internal.selectedVariables || [];
if (selected.length === 0) {
return [{ type: 'text', value: 'No variables selected' }];
}
return selected.map(key => ({
type: 'text',
value: `${key}: ${JSON.stringify(this._internal.outputValues[key])}`
}));
},
inputs: {
variables: {
type: {
name: 'stringlist',
allowEditOnly: true,
multiline: true
},
displayName: 'Variables',
group: 'General',
set: function(value) {
// Parse selected variables from stringlist
const selected = Array.isArray(value) ? value :
(typeof value === 'string' ? value.split(',').map(s => s.trim()).filter(Boolean) : []);
this._internal.selectedVariables = selected;
// Update output values
const config = configManager.getConfig();
for (const key of selected) {
this._internal.outputValues[key] = config[key];
}
// Flag all outputs as dirty
selected.forEach(key => {
if (this.hasOutput('out-' + key)) {
this.flagOutputDirty('out-' + key);
}
});
}
}
},
outputs: {},
methods: {
registerOutputIfNeeded: function(name) {
if (this.hasOutput(name)) return;
if (name.startsWith('out-')) {
const key = name.substring(4);
const config = configManager.getConfig();
const variable = configManager.getVariable(key);
// Determine output type based on variable type or infer from value
let outputType = '*';
if (variable) {
outputType = variable.type;
} else if (key in config) {
// Built-in config - infer type
const value = config[key];
if (typeof value === 'string') {
outputType = key.toLowerCase().includes('color') ? 'color' : 'string';
} else if (typeof value === 'number') {
outputType = 'number';
} else if (typeof value === 'boolean') {
outputType = 'boolean';
} else if (Array.isArray(value)) {
outputType = 'array';
} else if (typeof value === 'object') {
outputType = 'object';
}
}
this.registerOutput(name, {
type: outputType,
getter: function() {
return this._internal.outputValues[key];
}
});
}
}
}
};
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get available config keys
const allKeys = getAvailableConfigKeys(graphModel);
// Get selected variables
const selected = parameters.variables ?
(Array.isArray(parameters.variables) ? parameters.variables :
parameters.variables.split(',').map(s => s.trim()).filter(Boolean)) : [];
// Create output ports for selected variables
selected.forEach(key => {
const variable = graphModel.getMetaData('appConfig')?.variables?.find(v => v.key === key);
let type = '*';
if (variable) {
type = variable.type;
} else if (isBuiltInKey(key)) {
type = getBuiltInType(key);
}
ports.push({
name: 'out-' + key,
displayName: key,
plug: 'output',
type: type,
group: 'Values'
});
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
function getAvailableConfigKeys(graphModel) {
const config = graphModel.getMetaData('appConfig') || {};
const keys = [];
// Built-in keys
keys.push('appName', 'description', 'coverImage');
keys.push('ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor');
keys.push('pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor');
// Custom variables
if (config.variables) {
config.variables.forEach(v => keys.push(v.key));
}
return keys;
}
function isBuiltInKey(key) {
const builtIn = [
'appName', 'description', 'coverImage',
'ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor',
'pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor'
];
return builtIn.includes(key);
}
function getBuiltInType(key) {
const colorKeys = ['themeColor', 'pwaBackgroundColor'];
const booleanKeys = ['pwaEnabled'];
if (colorKeys.includes(key)) return 'color';
if (booleanKeys.includes(key)) return 'boolean';
return 'string';
}
module.exports = {
node: AppConfigNode,
setup: function(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
node.on('parameterUpdated', function(event) {
if (event.name === 'variables') {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
}
});
// Also update when app config changes
graphModel.on('metadataChanged.appConfig', function() {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.App Config', function(node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('App Config')) {
_managePortsForNode(node);
}
});
}
};
```
### 2. Variable Selector Property Editor
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ConfigVariableSelector.tsx`
```tsx
import React, { useState, useMemo } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { Checkbox } from '@noodl-core-ui/components/inputs/Checkbox';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Text } from '@noodl-core-ui/components/typography/Text';
import css from './ConfigVariableSelector.module.scss';
interface ConfigVariableSelectorProps {
value: string[];
onChange: (value: string[]) => void;
}
interface VariableOption {
key: string;
type: string;
category: string;
}
export function ConfigVariableSelector({ value, onChange }: ConfigVariableSelectorProps) {
const selected = new Set(value);
const options = useMemo(() => {
const config = ProjectModel.instance.getAppConfig();
const opts: VariableOption[] = [];
// Built-in: Identity
opts.push(
{ key: 'appName', type: 'string', category: 'Identity' },
{ key: 'description', type: 'string', category: 'Identity' },
{ key: 'coverImage', type: 'string', category: 'Identity' }
);
// Built-in: SEO
opts.push(
{ key: 'ogTitle', type: 'string', category: 'SEO' },
{ key: 'ogDescription', type: 'string', category: 'SEO' },
{ key: 'ogImage', type: 'string', category: 'SEO' },
{ key: 'favicon', type: 'string', category: 'SEO' },
{ key: 'themeColor', type: 'color', category: 'SEO' }
);
// Built-in: PWA
opts.push(
{ key: 'pwaEnabled', type: 'boolean', category: 'PWA' },
{ key: 'pwaShortName', type: 'string', category: 'PWA' },
{ key: 'pwaDisplay', type: 'string', category: 'PWA' },
{ key: 'pwaStartUrl', type: 'string', category: 'PWA' },
{ key: 'pwaBackgroundColor', type: 'color', category: 'PWA' }
);
// Custom variables
config.variables.forEach(v => {
opts.push({
key: v.key,
type: v.type,
category: v.category || 'Custom'
});
});
return opts;
}, []);
// Group by category
const grouped = useMemo(() => {
const groups: Record<string, VariableOption[]> = {};
options.forEach(opt => {
if (!groups[opt.category]) {
groups[opt.category] = [];
}
groups[opt.category].push(opt);
});
return groups;
}, [options]);
const toggleVariable = (key: string) => {
const newSelected = new Set(selected);
if (newSelected.has(key)) {
newSelected.delete(key);
} else {
newSelected.add(key);
}
onChange(Array.from(newSelected));
};
return (
<div className={css.Root}>
{Object.entries(grouped).map(([category, vars]) => (
<div key={category} className={css.Category}>
<Text className={css.CategoryLabel}>{category}</Text>
{vars.map(v => (
<label key={v.key} className={css.VariableRow}>
<Checkbox
isChecked={selected.has(v.key)}
onChange={() => toggleVariable(v.key)}
/>
<span className={css.VariableKey}>{v.key}</span>
<span className={css.VariableType}>{v.type}</span>
</label>
))}
</div>
))}
</div>
);
}
```
---
## Files to Modify
### 1. Rename Existing Config Node
**File:** `packages/noodl-runtime/src/nodes/std-library/data/confignode.js`
```javascript
// Change:
name: 'Config',
displayName: 'Config',
// To:
name: 'Noodl Cloud Config',
displayName: 'Noodl Cloud Config',
shortDesc: 'Access configuration from Noodl Cloud Service.',
```
### 2. Update Node Library Export
**File:** `packages/noodl-runtime/src/nodelibraryexport.js`
```javascript
// Add App Config node
require('./src/nodes/std-library/data/appconfignode'),
// Update node picker categories
{
name: 'Variables',
items: [
'Variable2',
'SetVariable',
'App Config', // Add here
// ...
]
}
```
### 3. Cloud Nodes Visibility
**File:** `packages/noodl-editor/src/editor/src/views/nodepicker/NodePicker.tsx` (or relevant file)
Add logic to hide cloud data nodes when no backend is connected:
```typescript
function shouldShowCloudNodes(): boolean {
const cloudService = ProjectModel.instance.getMetaData('cloudservices');
return cloudService && cloudService.endpoint;
}
function getFilteredCategories(categories: Category[]): Category[] {
const showCloud = shouldShowCloudNodes();
return categories.map(category => {
if (category.name === 'Cloud Data' || category.name === 'Read & Write Data') {
// Filter out cloud-specific nodes if no backend
if (!showCloud) {
const cloudNodes = [
'DbCollection2',
'DbModel2',
'Noodl Cloud Config', // Renamed node
// ... other cloud nodes
];
const filteredItems = category.items.filter(
item => !cloudNodes.includes(item)
);
// Add warning message if category is now empty or reduced
return {
...category,
items: filteredItems,
message: showCloud ? undefined : 'Please add a backend service to use cloud data nodes.'
};
}
}
return category;
});
}
```
### 4. Register Node Type Editor
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts`
Add handler for the variables property in App Config node:
```typescript
// In the port type handlers
if (nodeName === 'App Config' && portName === 'variables') {
return {
component: ConfigVariableSelector,
// ... props
};
}
```
---
## Testing Checklist
### App Config Node
- [ ] Node appears in node picker under Variables
- [ ] Can select/deselect variables in property panel
- [ ] Selected variables appear as outputs
- [ ] Output types match variable types
- [ ] Color variables output as color type
- [ ] Array variables output as array type
- [ ] Values are correct at runtime
- [ ] Node inspector shows current values
- [ ] Updates when app config changes in editor
### Cloud Node Visibility
- [ ] Cloud nodes hidden when no backend
- [ ] Warning message shows in category
- [ ] Cloud nodes visible when backend connected
- [ ] Existing cloud nodes in projects still work
- [ ] Renamed node appears as "Noodl Cloud Config"
### Variable Selector
- [ ] Shows all built-in config keys
- [ ] Shows custom variables
- [ ] Grouped by category
- [ ] Shows variable type
- [ ] Checkbox toggles selection
- [ ] Multiple selection works
---
## Notes for Implementer
### Dynamic Port Pattern
This node uses the dynamic port pattern where outputs are created based on the `variables` property. Study existing nodes like `DbCollection2` for reference on how to:
- Parse the stringlist input
- Create dynamic output ports
- Handle port updates when parameters change
### Type Preservation
It's important that output types match the declared variable types so that connections in the graph validate correctly. A color output should only connect to color inputs, etc.
### Cloud Service Detection
The cloud service information is stored in project metadata under `cloudservices`. Check for both the existence of the object and a valid endpoint URL.

View File

@@ -0,0 +1,397 @@
# CONFIG-004: SEO Build Integration
## Overview
Integrate app configuration values into the HTML build process to generate proper SEO meta tags, Open Graph tags, and favicon links.
**Estimated effort:** 8-10 hours
**Dependencies:** CONFIG-001, CONFIG-002
**Blocks:** None
---
## Objectives
1. Inject `<title>` tag from appName
2. Inject meta description tag
3. Inject Open Graph tags (og:title, og:description, og:image)
4. Inject Twitter Card tags
5. Inject favicon link
6. Inject theme-color meta tag
7. Support canonical URL (optional)
---
## Generated HTML Structure
```html
<!DOCTYPE html>
<html>
<head>
<!-- Basic Meta -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Amazing App</title>
<meta name="description" content="A visual programming app that makes building easy">
<!-- Theme Color -->
<meta name="theme-color" content="#d21f3c">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="My Amazing App">
<meta property="og:description" content="A visual programming app that makes building easy">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:url" content="https://example.com">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="My Amazing App">
<meta name="twitter:description" content="A visual programming app that makes building easy">
<meta name="twitter:image" content="https://example.com/og-image.jpg">
<!-- PWA Manifest (if enabled) -->
<link rel="manifest" href="/manifest.json">
{{#customHeadCode#}}
</head>
```
---
## Files to Modify
### 1. HTML Processor
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/processors/html-processor.ts`
Extend the existing processor:
```typescript
import { ProjectModel } from '@noodl-models/projectmodel';
import { AppConfig } from '@noodl/runtime/src/config/types';
export interface HtmlProcessorParameters {
title?: string;
headCode?: string;
indexJsPath?: string;
baseUrl?: string;
envVariables?: Record<string, string>;
}
export class HtmlProcessor {
constructor(public readonly project: ProjectModel) {}
public async process(content: string, parameters: HtmlProcessorParameters): Promise<string> {
const settings = this.project.getSettings();
const appConfig = this.project.getAppConfig();
let baseUrl = parameters.baseUrl || settings.baseUrl || '/';
if (!baseUrl.endsWith('/')) {
baseUrl = baseUrl + '/';
}
// Title from app config, falling back to settings, then default
const title = parameters.title || appConfig.identity.appName || settings.htmlTitle || 'Noodl App';
// Build head code with SEO tags
let headCode = this.generateSEOTags(appConfig, baseUrl);
headCode += settings.headCode || '';
if (parameters.headCode) {
headCode += parameters.headCode;
}
if (baseUrl !== '/') {
headCode = `<base href="${baseUrl}" target="_blank" />\n` + headCode;
}
// Inject into template
let injected = await this.injectIntoHtml(content, baseUrl);
injected = injected.replace('{{#title#}}', this.escapeHtml(title));
injected = injected.replace('{{#customHeadCode#}}', headCode);
injected = injected.replace(/%baseUrl%/g, baseUrl);
// ... rest of existing processing
return injected;
}
private generateSEOTags(config: AppConfig, baseUrl: string): string {
const tags: string[] = [];
// Description
const description = config.seo.ogDescription || config.identity.description;
if (description) {
tags.push(`<meta name="description" content="${this.escapeAttr(description)}">`);
}
// Theme color
if (config.seo.themeColor) {
tags.push(`<meta name="theme-color" content="${this.escapeAttr(config.seo.themeColor)}">`);
}
// Favicon
if (config.seo.favicon) {
const faviconPath = this.resolveAssetPath(config.seo.favicon, baseUrl);
const faviconType = this.getFaviconType(config.seo.favicon);
tags.push(`<link rel="icon" type="${faviconType}" href="${faviconPath}">`);
// Apple touch icon (use og:image or favicon)
const touchIcon = config.seo.ogImage || config.identity.coverImage || config.seo.favicon;
if (touchIcon) {
tags.push(`<link rel="apple-touch-icon" href="${this.resolveAssetPath(touchIcon, baseUrl)}">`);
}
}
// Open Graph
tags.push(...this.generateOpenGraphTags(config, baseUrl));
// Twitter Card
tags.push(...this.generateTwitterTags(config, baseUrl));
// PWA Manifest
if (config.pwa?.enabled) {
tags.push(`<link rel="manifest" href="${baseUrl}manifest.json">`);
}
return tags.join('\n ') + '\n';
}
private generateOpenGraphTags(config: AppConfig, baseUrl: string): string[] {
const tags: string[] = [];
tags.push(`<meta property="og:type" content="website">`);
const ogTitle = config.seo.ogTitle || config.identity.appName;
if (ogTitle) {
tags.push(`<meta property="og:title" content="${this.escapeAttr(ogTitle)}">`);
}
const ogDescription = config.seo.ogDescription || config.identity.description;
if (ogDescription) {
tags.push(`<meta property="og:description" content="${this.escapeAttr(ogDescription)}">`);
}
const ogImage = config.seo.ogImage || config.identity.coverImage;
if (ogImage) {
// OG image should be absolute URL
const imagePath = this.resolveAssetPath(ogImage, baseUrl);
tags.push(`<meta property="og:image" content="${imagePath}">`);
}
// og:url would need the deployment URL - could be added via env variable
// tags.push(`<meta property="og:url" content="${deployUrl}">`);
return tags;
}
private generateTwitterTags(config: AppConfig, baseUrl: string): string[] {
const tags: string[] = [];
// Use large image card if we have an image
const hasImage = config.seo.ogImage || config.identity.coverImage;
tags.push(`<meta name="twitter:card" content="${hasImage ? 'summary_large_image' : 'summary'}">`);
const title = config.seo.ogTitle || config.identity.appName;
if (title) {
tags.push(`<meta name="twitter:title" content="${this.escapeAttr(title)}">`);
}
const description = config.seo.ogDescription || config.identity.description;
if (description) {
tags.push(`<meta name="twitter:description" content="${this.escapeAttr(description)}">`);
}
if (hasImage) {
const imagePath = this.resolveAssetPath(
config.seo.ogImage || config.identity.coverImage!,
baseUrl
);
tags.push(`<meta name="twitter:image" content="${imagePath}">`);
}
return tags;
}
private resolveAssetPath(path: string, baseUrl: string): string {
if (!path) return '';
// Already absolute URL
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// Relative path - prepend base URL
const cleanPath = path.startsWith('/') ? path.substring(1) : path;
return baseUrl + cleanPath;
}
private getFaviconType(path: string): string {
if (path.endsWith('.ico')) return 'image/x-icon';
if (path.endsWith('.png')) return 'image/png';
if (path.endsWith('.svg')) return 'image/svg+xml';
return 'image/png';
}
private escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private escapeAttr(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private injectIntoHtml(template: string, pathPrefix: string) {
return new Promise<string>((resolve) => {
ProjectModules.instance.injectIntoHtml(
this.project._retainedProjectDirectory,
template,
pathPrefix,
resolve
);
});
}
}
```
### 2. Update HTML Template
**File:** `packages/noodl-viewer-react/static/index.html` (or equivalent)
Ensure template has the right placeholders:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{#title#}}</title>
{{#customHeadCode#}}
</head>
<body>
<div id="root"></div>
<%index_js%>
</body>
</html>
```
---
## Asset Handling
### Image Resolution
Images referenced in config (coverImage, ogImage, favicon) can be:
1. **Project file paths**: `/assets/images/cover.png` - copied to build output
2. **External URLs**: `https://example.com/image.jpg` - used as-is
3. **Uploaded files**: Handled through the existing project file system
**File:** Add to build process in `packages/noodl-editor/src/editor/src/utils/compilation/build/`
```typescript
async function copyConfigAssets(project: ProjectModel, outputDir: string): Promise<void> {
const config = project.getAppConfig();
const assets: string[] = [];
// Collect asset paths
if (config.identity.coverImage && !isExternalUrl(config.identity.coverImage)) {
assets.push(config.identity.coverImage);
}
if (config.seo.ogImage && !isExternalUrl(config.seo.ogImage)) {
assets.push(config.seo.ogImage);
}
if (config.seo.favicon && !isExternalUrl(config.seo.favicon)) {
assets.push(config.seo.favicon);
}
// Copy each asset to output
for (const asset of assets) {
const sourcePath = path.join(project._retainedProjectDirectory, asset);
const destPath = path.join(outputDir, asset);
if (await fileExists(sourcePath)) {
await ensureDir(path.dirname(destPath));
await copyFile(sourcePath, destPath);
}
}
}
function isExternalUrl(path: string): boolean {
return path.startsWith('http://') || path.startsWith('https://');
}
```
---
## Testing Checklist
### Build Output Tests
- [ ] Title tag contains app name
- [ ] Meta description present
- [ ] Theme color meta tag present
- [ ] Favicon link correct type and path
- [ ] Apple touch icon link present
- [ ] og:type is "website"
- [ ] og:title matches config
- [ ] og:description matches config
- [ ] og:image URL correct
- [ ] Twitter card type correct
- [ ] Twitter tags mirror OG tags
- [ ] Manifest link present when PWA enabled
- [ ] Manifest link absent when PWA disabled
### Asset Handling Tests
- [ ] Local image paths copied to build
- [ ] External URLs used as-is
- [ ] Missing assets don't break build
- [ ] Paths correct with custom baseUrl
### Edge Cases
- [ ] Empty description doesn't create empty tag
- [ ] Missing favicon doesn't break build
- [ ] Special characters escaped in meta content
- [ ] Very long descriptions truncated appropriately
---
## Notes for Implementer
### OG Image Requirements
Open Graph images have specific requirements:
- Minimum 200x200 pixels
- Recommended 1200x630 pixels
- Maximum 8MB file size
Consider adding validation in the App Setup panel.
### Canonical URL
The canonical URL (`og:url`) requires knowing the deployment URL, which isn't always known at build time. Options:
1. Add a `canonicalUrl` field to app config
2. Inject via environment variable at deploy time
3. Use JavaScript to set dynamically (loses SEO benefit)
### Testing SEO Output
Use tools like:
- https://developers.facebook.com/tools/debug/ (OG tags)
- https://cards-dev.twitter.com/validator (Twitter cards)
- Browser DevTools to inspect head tags

View File

@@ -0,0 +1,471 @@
# CONFIG-005: PWA Manifest Generation
## Overview
Generate a valid `manifest.json` file and auto-scale app icons from a single source image for Progressive Web App support.
**Estimated effort:** 10-14 hours
**Dependencies:** CONFIG-001, CONFIG-002
**Blocks:** None (enables Phase 5 Capacitor integration)
---
## Objectives
1. Generate `manifest.json` from app config
2. Auto-scale icons from single 512x512 source
3. Copy icons to build output
4. Validate PWA requirements
5. Generate service worker stub (optional)
---
## Generated manifest.json
```json
{
"name": "My Amazing App",
"short_name": "My App",
"description": "A visual programming app that makes building easy",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#d21f3c",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
```
---
## Files to Create
### 1. Manifest Generator
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/processors/manifest-processor.ts`
```typescript
import * as path from 'path';
import * as fs from 'fs-extra';
import { ProjectModel } from '@noodl-models/projectmodel';
import { AppConfig } from '@noodl/runtime/src/config/types';
// Standard PWA icon sizes
const ICON_SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
export interface ManifestProcessorOptions {
baseUrl?: string;
outputDir: string;
}
export class ManifestProcessor {
constructor(public readonly project: ProjectModel) {}
public async process(options: ManifestProcessorOptions): Promise<void> {
const config = this.project.getAppConfig();
// Only generate if PWA is enabled
if (!config.pwa?.enabled) {
return;
}
const baseUrl = options.baseUrl || '/';
// Generate icons from source
await this.generateIcons(config, options.outputDir, baseUrl);
// Generate manifest.json
const manifest = this.generateManifest(config, baseUrl);
const manifestPath = path.join(options.outputDir, 'manifest.json');
await fs.writeJson(manifestPath, manifest, { spaces: 2 });
}
private generateManifest(config: AppConfig, baseUrl: string): object {
const manifest: Record<string, any> = {
name: config.identity.appName,
short_name: config.pwa?.shortName || config.identity.appName,
description: config.identity.description || '',
start_url: config.pwa?.startUrl || '/',
display: config.pwa?.display || 'standalone',
background_color: config.pwa?.backgroundColor || '#ffffff',
theme_color: config.seo.themeColor || '#000000',
icons: this.generateIconsManifest(baseUrl)
};
// Optional fields
if (config.identity.description) {
manifest.description = config.identity.description;
}
return manifest;
}
private generateIconsManifest(baseUrl: string): object[] {
return ICON_SIZES.map(size => ({
src: `${baseUrl}icons/icon-${size}x${size}.png`,
sizes: `${size}x${size}`,
type: 'image/png'
}));
}
private async generateIcons(
config: AppConfig,
outputDir: string,
baseUrl: string
): Promise<void> {
const sourceIcon = config.pwa?.sourceIcon;
if (!sourceIcon) {
console.warn('PWA enabled but no source icon provided. Using placeholder.');
await this.generatePlaceholderIcons(outputDir);
return;
}
// Resolve source icon path
const sourcePath = this.resolveAssetPath(sourceIcon);
if (!await fs.pathExists(sourcePath)) {
console.warn(`Source icon not found: ${sourcePath}. Using placeholder.`);
await this.generatePlaceholderIcons(outputDir);
return;
}
// Ensure icons directory exists
const iconsDir = path.join(outputDir, 'icons');
await fs.ensureDir(iconsDir);
// Generate each size
for (const size of ICON_SIZES) {
const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`);
await this.resizeIcon(sourcePath, outputPath, size);
}
}
private async resizeIcon(
sourcePath: string,
outputPath: string,
size: number
): Promise<void> {
// Use sharp for image resizing
const sharp = require('sharp');
try {
await sharp(sourcePath)
.resize(size, size, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 0 }
})
.png()
.toFile(outputPath);
} catch (error) {
console.error(`Failed to resize icon to ${size}x${size}:`, error);
throw error;
}
}
private async generatePlaceholderIcons(outputDir: string): Promise<void> {
const iconsDir = path.join(outputDir, 'icons');
await fs.ensureDir(iconsDir);
// Generate simple colored placeholder icons
const sharp = require('sharp');
for (const size of ICON_SIZES) {
const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`);
// Create a simple colored square as placeholder
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 100, g: 100, b: 100, alpha: 1 }
}
})
.png()
.toFile(outputPath);
}
}
private resolveAssetPath(assetPath: string): string {
if (path.isAbsolute(assetPath)) {
return assetPath;
}
return path.join(this.project._retainedProjectDirectory, assetPath);
}
}
```
### 2. Icon Validation
**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/utils/icon-validation.ts`
```typescript
export interface IconValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
dimensions?: { width: number; height: number };
}
export async function validatePWAIcon(filePath: string): Promise<IconValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
try {
const sharp = require('sharp');
const metadata = await sharp(filePath).metadata();
const { width, height, format } = metadata;
// Check format
if (format !== 'png') {
errors.push('Icon must be a PNG file');
}
// Check dimensions
if (!width || !height) {
errors.push('Could not read image dimensions');
} else {
// Check if square
if (width !== height) {
errors.push(`Icon must be square. Current: ${width}x${height}`);
}
// Check minimum size
if (width < 512 || height < 512) {
errors.push(`Icon must be at least 512x512 pixels. Current: ${width}x${height}`);
}
// Warn if not exactly 512x512
if (width !== 512 && width > 512) {
warnings.push(`Icon will be scaled down from ${width}x${height} to required sizes`);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
dimensions: width && height ? { width, height } : undefined
};
} catch (error) {
return {
valid: false,
errors: [`Failed to read image: ${error.message}`],
warnings: []
};
}
}
```
### 3. Service Worker Stub (Optional)
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/templates/sw.js`
```javascript
// Basic service worker for PWA install prompt
// This is a minimal stub - users can replace with their own
const CACHE_NAME = 'app-cache-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html'
];
// Install event - cache basic assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(ASSETS_TO_CACHE))
);
});
// Fetch event - network first, fallback to cache
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
});
```
---
## Files to Modify
### 1. Build Pipeline
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/build.ts` (or equivalent)
Add manifest processing to build pipeline:
```typescript
import { ManifestProcessor } from './processors/manifest-processor';
async function buildProject(project: ProjectModel, options: BuildOptions): Promise<void> {
// ... existing build steps ...
// Generate PWA manifest and icons
const manifestProcessor = new ManifestProcessor(project);
await manifestProcessor.process({
baseUrl: options.baseUrl,
outputDir: options.outputDir
});
// ... rest of build ...
}
```
### 2. Deploy Build
Ensure the deploy process also runs manifest generation:
**File:** `packages/noodl-editor/src/editor/src/utils/compilation/deploy/`
```typescript
// Include manifest generation in deploy build
await manifestProcessor.process({
baseUrl: deployConfig.baseUrl || '/',
outputDir: deployOutputDir
});
```
---
## Icon Size Reference
| Size | Usage |
|------|-------|
| 72x72 | Android home screen (legacy) |
| 96x96 | Android home screen |
| 128x128 | Chrome Web Store |
| 144x144 | IE/Edge pinned sites |
| 152x152 | iPad home screen |
| 192x192 | Android home screen (modern), Chrome install |
| 384x384 | Android splash screen |
| 512x512 | Android splash screen, PWA install prompt |
---
## Testing Checklist
### Manifest Generation
- [ ] manifest.json created when PWA enabled
- [ ] manifest.json not created when PWA disabled
- [ ] name field matches appName
- [ ] short_name matches config or falls back to name
- [ ] description present if configured
- [ ] start_url correct
- [ ] display mode correct
- [ ] background_color correct
- [ ] theme_color correct
- [ ] icons array has all 8 sizes
### Icon Generation
- [ ] All 8 icon sizes generated
- [ ] Icons are valid PNG files
- [ ] Icons maintain aspect ratio
- [ ] Icons have correct dimensions
- [ ] Placeholder icons generated when no source
- [ ] Warning shown when source icon missing
- [ ] Error shown for invalid source format
### Validation
- [ ] Non-square icons rejected
- [ ] Non-PNG icons rejected
- [ ] Too-small icons rejected
- [ ] Valid icons pass validation
- [ ] Warnings for oversized icons
### Integration
- [ ] manifest.json linked in HTML head
- [ ] Icons accessible at correct paths
- [ ] PWA install prompt appears in Chrome
- [ ] App installs correctly
- [ ] Installed app shows correct icon
- [ ] Splash screen displays correctly
---
## Notes for Implementer
### Sharp Dependency
The `sharp` library is required for image processing. It should already be available in the editor dependencies. If not:
```bash
npm install sharp --save
```
### Service Worker Considerations
The basic service worker stub is intentionally minimal. Advanced features like:
- Offline caching strategies
- Push notifications
- Background sync
...should be implemented by users through custom code or future Noodl features.
### Capacitor Integration (Phase 5)
The icon generation logic will be reused for Capacitor builds:
- iOS requires specific sizes: 1024, 180, 167, 152, 120, 76, 60, 40, 29, 20
- Android uses adaptive icons with foreground/background layers
Consider making icon generation configurable for different target platforms.
### Testing PWA Installation
Test PWA installation on:
- Chrome (desktop and mobile)
- Edge (desktop)
- Safari (iOS - via Add to Home Screen)
- Firefox (Android)
Use Chrome DevTools > Application > Manifest to verify manifest is valid.

View File

@@ -0,0 +1,403 @@
# CONFIG-006: Expression System Integration
## Overview
Integrate `Noodl.Config` access into the expression evaluator with autocomplete support for config keys.
**Estimated effort:** 4-6 hours
**Dependencies:** CONFIG-001, TASK-006 (Expression System Overhaul)
**Blocks:** None
---
## Objectives
1. Make `Noodl.Config` accessible in Expression nodes
2. Add autocomplete for config keys in expression editor
3. Provide type hints for config values
4. Handle undefined config keys gracefully
---
## Expression Usage Examples
```javascript
// Simple value access
Noodl.Config.appName
// Color in style binding
Noodl.Config.primaryColor
// Conditional with config
Noodl.Config.pwaEnabled ? "Install App" : "Use in Browser"
// Template literal
`Welcome to ${Noodl.Config.appName}!`
// Array access
Noodl.Config.menuItems[0].label
// Object property
Noodl.Config.apiSettings.timeout
```
---
## Files to Modify
### 1. Expression Evaluator Context
**File:** `packages/noodl-runtime/src/nodes/std-library/expression.js` (or new expression-evaluator module from TASK-006)
Add Noodl.Config to the expression context:
```javascript
// In the expression preamble or context setup
const { configManager } = require('../../../config/config-manager');
function createExpressionContext() {
return {
// Math helpers
min: Math.min,
max: Math.max,
cos: Math.cos,
sin: Math.sin,
// ... other existing helpers ...
// Noodl globals
Noodl: {
Variables: Model.get('--ndl--global-variables'),
Objects: objectsProxy,
Arrays: arraysProxy,
Config: configManager.getConfig() // Add Config access
}
};
}
```
If following TASK-006's enhanced expression node pattern:
**File:** `packages/noodl-runtime/src/expression-evaluator.ts`
```typescript
import { configManager } from './config/config-manager';
export function createExpressionContext(nodeContext: NodeContext): ExpressionContext {
return {
// ... existing context ...
Noodl: {
// ... existing Noodl properties ...
Config: configManager.getConfig()
}
};
}
```
### 2. Expression Editor Autocomplete
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/` (or relevant autocomplete module)
Add Config keys to autocomplete suggestions:
```typescript
import { ProjectModel } from '@noodl-models/projectmodel';
import { AppConfig } from '@noodl/runtime/src/config/types';
interface AutocompleteItem {
label: string;
kind: 'property' | 'method' | 'variable';
detail?: string;
documentation?: string;
}
function getConfigAutocompletions(prefix: string): AutocompleteItem[] {
const config = ProjectModel.instance.getAppConfig();
const items: AutocompleteItem[] = [];
// Built-in config keys
const builtInKeys = [
{ key: 'appName', type: 'string', doc: 'Application name' },
{ key: 'description', type: 'string', doc: 'Application description' },
{ key: 'coverImage', type: 'string', doc: 'Cover image path' },
{ key: 'ogTitle', type: 'string', doc: 'Open Graph title' },
{ key: 'ogDescription', type: 'string', doc: 'Open Graph description' },
{ key: 'ogImage', type: 'string', doc: 'Open Graph image' },
{ key: 'favicon', type: 'string', doc: 'Favicon path' },
{ key: 'themeColor', type: 'color', doc: 'Theme color' },
{ key: 'pwaEnabled', type: 'boolean', doc: 'PWA enabled state' },
{ key: 'pwaShortName', type: 'string', doc: 'PWA short name' },
{ key: 'pwaDisplay', type: 'string', doc: 'PWA display mode' },
{ key: 'pwaStartUrl', type: 'string', doc: 'PWA start URL' },
{ key: 'pwaBackgroundColor', type: 'color', doc: 'PWA background color' }
];
// Add built-in keys
for (const { key, type, doc } of builtInKeys) {
if (key.startsWith(prefix)) {
items.push({
label: key,
kind: 'property',
detail: type,
documentation: doc
});
}
}
// Add custom variables
for (const variable of config.variables) {
if (variable.key.startsWith(prefix)) {
items.push({
label: variable.key,
kind: 'property',
detail: variable.type,
documentation: variable.description
});
}
}
return items;
}
// In the autocomplete provider
function provideCompletions(
model: editor.ITextModel,
position: Position
): CompletionList {
const lineContent = model.getLineContent(position.lineNumber);
const beforeCursor = lineContent.substring(0, position.column - 1);
// Check if typing after "Noodl.Config."
const configMatch = beforeCursor.match(/Noodl\.Config\.(\w*)$/);
if (configMatch) {
const prefix = configMatch[1];
const items = getConfigAutocompletions(prefix);
return {
suggestions: items.map(item => ({
label: item.label,
kind: monaco.languages.CompletionItemKind.Property,
detail: item.detail,
documentation: item.documentation,
insertText: item.label,
range: new Range(
position.lineNumber,
position.column - prefix.length,
position.lineNumber,
position.column
)
}))
};
}
// Check if typing "Noodl."
const noodlMatch = beforeCursor.match(/Noodl\.(\w*)$/);
if (noodlMatch) {
const prefix = noodlMatch[1];
const noodlProps = ['Variables', 'Objects', 'Arrays', 'Config'];
return {
suggestions: noodlProps
.filter(p => p.startsWith(prefix))
.map(prop => ({
label: prop,
kind: monaco.languages.CompletionItemKind.Module,
insertText: prop,
range: new Range(
position.lineNumber,
position.column - prefix.length,
position.lineNumber,
position.column
)
}))
};
}
return { suggestions: [] };
}
```
### 3. Type Hints
**File:** Add type inference for config values
```typescript
function getConfigValueType(key: string): string {
const config = ProjectModel.instance.getAppConfig();
// Check custom variables first
const variable = config.variables.find(v => v.key === key);
if (variable) {
return variable.type;
}
// Built-in type mappings
const typeMap: Record<string, string> = {
appName: 'string',
description: 'string',
coverImage: 'string',
ogTitle: 'string',
ogDescription: 'string',
ogImage: 'string',
favicon: 'string',
themeColor: 'color',
pwaEnabled: 'boolean',
pwaShortName: 'string',
pwaDisplay: 'string',
pwaStartUrl: 'string',
pwaBackgroundColor: 'color'
};
return typeMap[key] || 'any';
}
```
### 4. Error Handling
**File:** `packages/noodl-runtime/src/config/config-manager.ts`
Ensure graceful handling of undefined keys:
```typescript
// In the config proxy (from CONFIG-001)
get(target, prop: string) {
if (prop in target) {
return target[prop];
}
// Log warning in development
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Noodl.Config.${prop} is not defined. ` +
`Add it in App Setup or check for typos.`
);
}
return undefined;
}
```
---
## Type Declarations Update
**File:** `packages/noodl-viewer-react/static/viewer/global.d.ts.keep`
Enhance the Config type declaration with JSDoc:
```typescript
declare namespace Noodl {
/**
* App configuration values defined in App Setup.
*
* Config values are static and cannot be modified at runtime.
* Use `Noodl.Variables` for values that need to change.
*
* @example
* // Access app name
* const name = Noodl.Config.appName;
*
* // Use in template literal
* const greeting = `Welcome to ${Noodl.Config.appName}!`;
*
* // Access custom variable
* const color = Noodl.Config.primaryColor;
*
* @see https://docs.noodl.net/config
*/
const Config: Readonly<{
/** Application name */
readonly appName: string;
/** Application description */
readonly description: string;
/** Cover image path or URL */
readonly coverImage?: string;
/** Open Graph title (defaults to appName) */
readonly ogTitle: string;
/** Open Graph description (defaults to description) */
readonly ogDescription: string;
/** Open Graph image path or URL */
readonly ogImage?: string;
/** Favicon path or URL */
readonly favicon?: string;
/** Theme color (hex format) */
readonly themeColor?: string;
/** Whether PWA is enabled */
readonly pwaEnabled: boolean;
/** PWA short name */
readonly pwaShortName?: string;
/** PWA display mode */
readonly pwaDisplay?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
/** PWA start URL */
readonly pwaStartUrl?: string;
/** PWA background color */
readonly pwaBackgroundColor?: string;
/** Custom configuration variables */
readonly [key: string]: any;
}>;
}
```
---
## Testing Checklist
### Expression Access
- [ ] `Noodl.Config.appName` returns correct value
- [ ] `Noodl.Config.primaryColor` returns color value
- [ ] `Noodl.Config.menuItems` returns array
- [ ] Nested access works: `Noodl.Config.menuItems[0].label`
- [ ] Template literals work: `` `Hello ${Noodl.Config.appName}` ``
- [ ] Undefined keys return undefined (no crash)
- [ ] Warning logged for undefined keys in dev
### Autocomplete
- [ ] Typing "Noodl." suggests "Config"
- [ ] Typing "Noodl.Config." shows all config keys
- [ ] Built-in keys show correct types
- [ ] Custom variables appear in suggestions
- [ ] Variable descriptions show in autocomplete
- [ ] Autocomplete filters as you type
### Integration
- [ ] Works in Expression nodes
- [ ] Works in Function nodes
- [ ] Works in Script nodes
- [ ] Works in inline expressions (if TASK-006 complete)
- [ ] Values update when config changes (editor refresh)
---
## Notes for Implementer
### TASK-006 Dependency
This task should be implemented alongside or after TASK-006 (Expression System Overhaul). If TASK-006 introduces a new expression evaluator module, integrate Config there. Otherwise, update the existing expression.js.
### Monaco Editor
The autocomplete examples assume Monaco editor is used. Adjust the implementation based on the actual editor component used in the expression input fields.
### Performance
The config object is frozen and created once at startup. Accessing it in expressions should have minimal performance impact. However, avoid calling `getConfig()` repeatedly - cache the reference.
### Cloud Functions
Remember to also update `packages/noodl-viewer-cloud/src/noodl-js-api.js` if cloud functions need Config access.

View File

@@ -0,0 +1,367 @@
# TASK-007: App Configuration & Environment System
## Overview
A new "App Setup" sidebar panel for defining app-wide configuration values (`Noodl.Config`), SEO metadata, PWA manifest generation, and an App Config node for accessing values without expressions.
**Estimated effort:** 64-86 hours
**Priority:** High - Foundation for theming, SEO, PWA, and deployment features
**Phase:** 3 (Editor UX Overhaul)
---
## Problem Statement
### Current Pain Points
1. **No central place for app metadata**: App name, description, and SEO values are scattered or missing
2. **Global variables require node clutter**: Users create Object/Variables nodes at App level and wire them everywhere, creating visual noise
3. **No PWA support**: Users must manually create manifest.json and configure service workers
4. **SEO is an afterthought**: No built-in way to set meta tags, Open Graph, or favicon
5. **Theming is manual**: No standard pattern for defining color palettes or configuration values
### User Stories
- *"I want to define my app's primary color once and reference it everywhere"*
- *"I want my app to appear correctly when shared on social media"*
- *"I want to add my app to home screen on mobile with proper icons"*
- *"I want a simple way to access config values without writing expressions"*
---
## Solution
### New Namespace: `Noodl.Config`
A **static, immutable** configuration namespace separate from `Noodl.Variables`:
```javascript
// Static - set once at app initialization, cannot change at runtime
Noodl.Config.appName // "My Amazing App"
Noodl.Config.primaryColor // "#d21f3c"
Noodl.Config.apiBaseUrl // "https://api.example.com"
Noodl.Config.menuItems // [{name: "Home", path: "/"}, ...]
// This is NOT allowed (throws or is ignored):
Noodl.Config.primaryColor = "#000000"; // ❌ Config is immutable
```
### Design Principles
1. **Config is static**: Values are "baked in" at app load - use Variables for runtime changes
2. **Type-aware editing**: Color picker for colors, array editor for arrays, etc.
3. **Required defaults**: Core metadata (name, description) always exists
4. **Expression + Node access**: Both `Noodl.Config.x` in expressions AND an App Config node
5. **Build-time injection**: SEO tags and PWA manifest generated during build/deploy
---
## Architecture
### Data Model
```typescript
interface AppConfig {
// Required sections (non-deletable)
identity: {
appName: string;
description: string;
coverImage?: string; // Path or URL
};
seo: {
ogTitle?: string; // Defaults to appName
ogDescription?: string; // Defaults to description
ogImage?: string; // Defaults to coverImage
favicon?: string;
themeColor?: string;
};
// Optional section
pwa?: {
enabled: boolean;
shortName?: string;
startUrl: string; // Default: "/"
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
backgroundColor?: string;
sourceIcon?: string; // Single source, auto-scaled to required sizes
};
// User-defined variables
variables: ConfigVariable[];
}
interface ConfigVariable {
key: string; // Valid JS identifier
type: ConfigType;
value: any;
description?: string; // Tooltip in UI
category?: string; // Grouping (defaults to "Custom")
validation?: {
required?: boolean;
pattern?: string; // Regex for strings
min?: number; // For numbers
max?: number;
};
}
type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object';
```
### Storage Location
Stored in `project.json` under metadata:
```json
{
"metadata": {
"appConfig": {
"identity": {
"appName": "My App",
"description": "An amazing app"
},
"seo": {
"themeColor": "#d21f3c"
},
"pwa": {
"enabled": true,
"display": "standalone"
},
"variables": [
{
"key": "primaryColor",
"type": "color",
"value": "#d21f3c",
"category": "Theme"
}
]
}
}
}
```
---
## UI Design
### App Setup Panel
New top-level sidebar tab (migrating some project settings here):
```
┌─────────────────────────────────────────────────┐
│ App Setup [?] │
├─────────────────────────────────────────────────┤
│ │
│ APP IDENTITY │
│ ┌─────────────────────────────────────────────┐ │
│ │ App Name [My Amazing App ] │ │
│ │ Description [A visual programming... ] │ │
│ │ [that makes building easy ] │ │
│ │ Cover Image [🖼️ cover.png ][Browse] │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ SEO & METADATA │
│ ┌─────────────────────────────────────────────┐ │
│ │ OG Title [ ] │ │
│ │ └ defaults to App Name │ │
│ │ OG Description [ ] │ │
│ │ └ defaults to Description │ │
│ │ OG Image [🖼️ ][Browse] │ │
│ │ └ defaults to Cover Image │ │
│ │ Favicon [🖼️ favicon.ico ][Browse] │ │
│ │ Theme Color [■ #d21f3c ] │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ▶ PWA CONFIGURATION [Enable] │
│ ┌─────────────────────────────────────────────┐ │
│ │ Short Name [My App ] │ │
│ │ Display Mode [Standalone ▼ ] │ │
│ │ Start URL [/ ] │ │
│ │ Background [■ #ffffff ] │ │
│ │ App Icon [🖼️ icon-512.png][Browse] │ │
│ │ └ 512x512 PNG, auto-scaled │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ CONFIGURATION VARIABLES [+ Add New] │
│ ┌─────────────────────────────────────────────┐ │
│ │ ┌─ Theme ─────────────────────────────────┐ │ │
│ │ │ primaryColor color [■ #d21f3c ][⋮]│ │ │
│ │ │ secondaryColor color [■ #1f3cd2 ][⋮]│ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ ┌─ API ───────────────────────────────────┐ │ │
│ │ │ apiBaseUrl string [https://... ][⋮]│ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ ┌─ Custom ────────────────────────────────┐ │ │
│ │ │ maxUploadSize number [10 ][⋮]│ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
```
### App Config Node
```
┌──────────────────────────────┐
│ App Config │
├──────────────────────────────┤
│ Variables │
│ ┌──────────────────────────┐ │
│ │ ☑ primaryColor │ │
│ │ ☑ apiBaseUrl │ │
│ │ ☐ secondaryColor │ │
│ │ ☑ menuItems │ │
│ └──────────────────────────┘ │
├──────────────────────────────┤
│ ○ primaryColor │──→
│ ○ apiBaseUrl │──→
│ ○ menuItems │──→
└──────────────────────────────┘
```
---
## Subtasks
| Task | Description | Estimate | Dependencies |
|------|-------------|----------|--------------|
| [CONFIG-001](./CONFIG-001-infrastructure.md) | Core Infrastructure | 14-18h | None |
| [CONFIG-002](./CONFIG-002-app-setup-panel.md) | App Setup Panel UI | 18-24h | CONFIG-001 |
| [CONFIG-003](./CONFIG-003-app-config-node.md) | App Config Node | 10-14h | CONFIG-001 |
| [CONFIG-004](./CONFIG-004-seo-integration.md) | SEO Build Integration | 8-10h | CONFIG-001, CONFIG-002 |
| [CONFIG-005](./CONFIG-005-pwa-manifest.md) | PWA Manifest Generation | 10-14h | CONFIG-001, CONFIG-002 |
| [CONFIG-006](./CONFIG-006-expression-integration.md) | Expression System Integration | 4-6h | CONFIG-001, TASK-006 |
**Total: 64-86 hours**
---
## Cloud Service Node UX Improvement
**Prerequisite cleanup task** (can be done as part of CONFIG-003 or separately):
### Problem
The existing "Config" node accesses Noodl Cloud Service (Parse Server) configuration. This will cause confusion with the new App Config system.
### Solution
1. **Rename existing node**: `Config``Noodl Cloud Config`
2. **Hide cloud nodes when no backend**: If no cloud service is connected, hide ALL nodes in the "Cloud Data" category from the node picker
3. **Show guidance**: Display "Please add a backend service" message in that category until a service is connected
4. **Preserve existing nodes**: Don't break legacy projects - existing nodes continue to work, just hidden from picker
### Files to modify:
- `packages/noodl-runtime/src/nodes/std-library/data/confignode.js` - Rename
- `packages/noodl-editor/src/editor/src/views/nodepicker/` - Category visibility logic
- `packages/noodl-runtime/src/nodelibraryexport.js` - Update node name in exports
---
## Integration Points
### With DEPLOY-003 (Environment Profiles)
DEPLOY-003 can **override** Config values per environment:
```
App Setup Panel DEPLOY-003 Profiles
───────────────── ───────────────────
apiBaseUrl: "dev" → Production: "https://api.prod.com"
Staging: "https://api.staging.com"
Development: (uses default)
```
The App Setup panel shows canonical/default values. DEPLOY-003 shows environment-specific overrides.
### With TASK-006 (Expression System)
The enhanced expression system will provide:
- `Noodl.Config.xxx` access in expressions
- Autocomplete for config keys
- Type hints
### With Phase 5 (Capacitor Deployment)
PWA icon generation will be reused for Capacitor app icons:
- Same 512x512 source image
- Generate iOS/Android required sizes
- Include in platform-specific builds
---
## Component Export Behavior
When exporting a component that uses `Noodl.Config.primaryColor`:
| Variable Type | Export Behavior |
|---------------|-----------------|
| **Custom variables** | Hard-coded to current values |
| **Default variables** (appName, etc.) | Reference preserved |
The importer is responsible for updating hard-coded values if needed.
---
## Testing Checklist
### Manual Testing
- [ ] App Setup panel appears in sidebar
- [ ] Can edit all identity fields
- [ ] Can edit SEO fields (with defaults shown)
- [ ] Can enable/disable PWA section
- [ ] Can add custom variables with different types
- [ ] Color picker works for color type
- [ ] Array editor works for array type
- [ ] Categories group variables correctly
- [ ] App Config node shows all custom variables
- [ ] Selected variables appear as outputs
- [ ] Output types match variable types
- [ ] `Noodl.Config.xxx` accessible in expressions
- [ ] SEO tags appear in built HTML
- [ ] manifest.json generated with correct values
- [ ] PWA icons auto-scaled from source
### Build Testing
- [ ] Export includes correct meta tags
- [ ] manifest.json valid and complete
- [ ] Icons generated at all required sizes
- [ ] Theme color in HTML head
- [ ] Open Graph tags render correctly
---
## Success Criteria
1. **Discoverable**: New users find App Setup easily in sidebar
2. **Complete**: SEO and PWA work without manual file editing
3. **Type-safe**: Color variables show color pickers, arrays show array editors
4. **Non-breaking**: Existing projects work, cloud nodes still function
5. **Extensible**: DEPLOY-003 can override values per environment
---
## Open Questions (Resolved)
| Question | Decision |
|----------|----------|
| Namespace name? | `Noodl.Config` |
| Static or reactive? | Static (immutable at runtime) |
| Panel location? | New top-level sidebar tab |
| PWA icons? | Single 512x512 source, auto-scaled |
| Categories required? | Optional, ungrouped → "Custom" |
| Loaded signal on node? | No, overkill for static values |
| ENV node name? | "App Config" |
---
## References
- Existing project settings: `views/panels/ProjectSettingsPanel/`
- Port type editors: `views/panels/propertyeditor/DataTypes/Ports.ts`
- HTML build processor: `utils/compilation/build/processors/html-processor.ts`
- Expression system: `TASK-006-expressions-overhaul/`
- Deploy settings: `TASK-005-deployment-automation/DEPLOY-003-deploy-settings.md`
- Cloud config node: `noodl-runtime/src/nodes/std-library/data/confignode.js`