mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
New data query node for Directus backend integration
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private escapeAttr(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user