├── .nvmrc ├── strapi ├── src │ ├── api │ │ ├── .gitkeep │ │ └── post │ │ │ ├── routes │ │ │ └── post.js │ │ │ ├── services │ │ │ └── post.js │ │ │ ├── controllers │ │ │ └── post.js │ │ │ └── content-types │ │ │ └── post │ │ │ └── schema.json │ ├── extensions │ │ └── .gitkeep │ ├── admin │ │ ├── webpack.config.example.js │ │ └── app.example.js │ └── index.js ├── public │ ├── uploads │ │ └── .gitkeep │ └── robots.txt ├── database │ └── migrations │ │ └── .gitkeep ├── .eslintignore ├── favicon.png ├── config │ ├── api.js │ ├── admin.js │ ├── middlewares.js │ ├── server.js │ └── database.js ├── types │ └── generated │ │ └── components.d.ts ├── jsconfig.json ├── .env.example ├── .editorconfig ├── .eslintrc ├── package.json ├── .gitignore └── README.md ├── .gitignore ├── .husky └── pre-commit ├── integration-tests ├── base │ ├── graphql-response.json │ ├── preview.html │ └── website.json ├── modules.html ├── visibility-truthy │ ├── preview.html │ └── website.json └── graphql-modules.json ├── babel.config.js ├── tsconfig.cjs.json ├── __mocks__ └── graphql-mocks.js ├── jest.config.js ├── .github └── workflows │ ├── test.yml │ └── npm-publish.yml ├── src ├── filters │ ├── liquid.test.ts │ └── index.ts ├── storage.ts ├── utils.test.ts ├── datasources │ └── graphql-introspection-query.ts ├── commands.ts ├── index.ts ├── model │ ├── dataSourceRegistry.ts │ ├── state.test.ts │ ├── queryBuilder.ts │ ├── dataSourceRegistry.test.ts │ ├── previewDataLoader.ts │ ├── state.ts │ ├── expressionEvaluator.ts │ ├── completion.ts │ ├── dataSourceManager.ts │ ├── ExpressionTree.ts │ └── token.ts ├── storage.test.ts ├── test-data.ts ├── view │ ├── index.ts │ ├── properties-editor.ts │ ├── defaultStyles.ts │ └── custom-states-editor.ts └── types.ts ├── tsconfig.json ├── eslint.config.mjs ├── package.json ├── _index.html └── example.graphql /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /strapi/src/api/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /strapi/public/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /strapi/src/extensions/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /strapi/database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /strapi/.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | build 3 | **/node_modules/** 4 | -------------------------------------------------------------------------------- /strapi/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silexlabs/grapesjs-data-source/HEAD/strapi/favicon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .env 3 | .DS_Store 4 | private/ 5 | /locale 6 | node_modules/ 7 | *.log 8 | stats.json 9 | .vscode 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm install --package-lock-only --workspaces false 2 | git update-index --again 3 | npm test 4 | npm run lint 5 | -------------------------------------------------------------------------------- /integration-tests/base/graphql-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "__typename": "Query", 4 | "continents": [] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /strapi/config/api.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rest: { 3 | defaultLimit: 25, 4 | maxLimit: 100, 5 | withCount: true, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /strapi/public/robots.txt: -------------------------------------------------------------------------------- 1 | # To prevent search engines from seeing the site altogether, uncomment the next two lines: 2 | # User-Agent: * 3 | # Disallow: / 4 | -------------------------------------------------------------------------------- /strapi/types/generated/components.d.ts: -------------------------------------------------------------------------------- 1 | import type { Schema, Attribute } from '@strapi/strapi'; 2 | 3 | declare module '@strapi/types' { 4 | export module Shared {} 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | plugins: [], 7 | }; -------------------------------------------------------------------------------- /strapi/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "nodenext", 4 | "target": "ES2021", 5 | "checkJs": true, 6 | "allowJs": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "./dist/cjs" 6 | }, 7 | "include": ["src/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /strapi/.env.example: -------------------------------------------------------------------------------- 1 | HOST=0.0.0.0 2 | PORT=1337 3 | APP_KEYS="toBeModified1,toBeModified2" 4 | API_TOKEN_SALT=tobemodified 5 | ADMIN_JWT_SECRET=tobemodified 6 | TRANSFER_TOKEN_SALT=tobemodified 7 | JWT_SECRET=tobemodified 8 | -------------------------------------------------------------------------------- /strapi/src/api/post/routes/post.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * post router 5 | */ 6 | 7 | const { createCoreRouter } = require('@strapi/strapi').factories; 8 | 9 | module.exports = createCoreRouter('api::post.post'); 10 | -------------------------------------------------------------------------------- /strapi/src/api/post/services/post.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * post service 5 | */ 6 | 7 | const { createCoreService } = require('@strapi/strapi').factories; 8 | 9 | module.exports = createCoreService('api::post.post'); 10 | -------------------------------------------------------------------------------- /strapi/src/api/post/controllers/post.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * post controller 5 | */ 6 | 7 | const { createCoreController } = require('@strapi/strapi').factories; 8 | 9 | module.exports = createCoreController('api::post.post'); 10 | -------------------------------------------------------------------------------- /strapi/config/admin.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | auth: { 3 | secret: env('ADMIN_JWT_SECRET'), 4 | }, 5 | apiToken: { 6 | salt: env('API_TOKEN_SALT'), 7 | }, 8 | transfer: { 9 | token: { 10 | salt: env('TRANSFER_TOKEN_SALT'), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /strapi/config/middlewares.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | 'strapi::errors', 3 | 'strapi::security', 4 | 'strapi::cors', 5 | 'strapi::poweredBy', 6 | 'strapi::logger', 7 | 'strapi::query', 8 | 'strapi::body', 9 | 'strapi::session', 10 | 'strapi::favicon', 11 | 'strapi::public', 12 | ]; 13 | -------------------------------------------------------------------------------- /strapi/config/server.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | host: env('HOST', '0.0.0.0'), 3 | port: env.int('PORT', 1337), 4 | app: { 5 | keys: env.array('APP_KEYS'), 6 | }, 7 | webhooks: { 8 | populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false), 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /integration-tests/base/preview.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

About this demo

4 |
5 |
6 | -------------------------------------------------------------------------------- /strapi/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /strapi/src/admin/webpack.config.example.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable no-unused-vars */ 4 | module.exports = (config, webpack) => { 5 | // Note: we provide webpack above so you should not `require` it 6 | // Perform customizations to webpack config 7 | // Important: return the modified config 8 | return config; 9 | }; 10 | -------------------------------------------------------------------------------- /__mocks__/graphql-mocks.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | exports.directusTestSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'directus-test-schema.json'), 'utf8')) 5 | exports.strapiSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'strapi-schema.json'), 'utf8')) 6 | exports.simpleSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'simple-schema.json'), 'utf8')) 7 | exports.squidexSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'squidex-schema.json'), 'utf8')) 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | testMatch: [ 5 | '**/src/**/*.test.ts' 6 | ], 7 | collectCoverageFrom: [ 8 | 'src/**/*.ts', 9 | '!src/**/*.test.ts', 10 | '!src/**/test-data.ts' 11 | ], 12 | transformIgnorePatterns: [ 13 | 'node_modules/(?!(@jest/.*|ts-jest|.*\\.mjs$))' 14 | ], 15 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 16 | globals: { 17 | 'ts-jest': { 18 | isolatedModules: true 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /strapi/src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | /** 5 | * An asynchronous register function that runs before 6 | * your application is initialized. 7 | * 8 | * This gives you an opportunity to extend code. 9 | */ 10 | register(/*{ strapi }*/) {}, 11 | 12 | /** 13 | * An asynchronous bootstrap function that runs before 14 | * your application gets started. 15 | * 16 | * This gives you an opportunity to set up your data model, 17 | * run jobs, or perform some special logic. 18 | */ 19 | bootstrap(/*{ strapi }*/) {}, 20 | }; 21 | -------------------------------------------------------------------------------- /strapi/src/api/post/content-types/post/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "collectionType", 3 | "collectionName": "posts", 4 | "info": { 5 | "singularName": "post", 6 | "pluralName": "posts", 7 | "displayName": "Post" 8 | }, 9 | "options": { 10 | "draftAndPublish": true 11 | }, 12 | "pluginOptions": {}, 13 | "attributes": { 14 | "title": { 15 | "type": "string" 16 | }, 17 | "content": { 18 | "type": "richtext" 19 | }, 20 | "author": { 21 | "type": "relation", 22 | "relation": "oneToOne", 23 | "target": "admin::user" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | 10 | strategy: 11 | matrix: 12 | node-version: [20.x] 13 | # os: [macos-latest, windows-latest, ubuntu-latest] 14 | os: [ubuntu-latest] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm install 23 | - run: npm run build 24 | - run: npm test 25 | env: 26 | CI: true 27 | - run: npm run lint 28 | -------------------------------------------------------------------------------- /strapi/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "env": { 5 | "commonjs": true, 6 | "es6": true, 7 | "node": true, 8 | "browser": false 9 | }, 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true, 13 | "jsx": false 14 | }, 15 | "sourceType": "module" 16 | }, 17 | "globals": { 18 | "strapi": true 19 | }, 20 | "rules": { 21 | "indent": ["error", 2, { "SwitchCase": 1 }], 22 | "linebreak-style": ["error", "unix"], 23 | "no-console": 0, 24 | "quotes": ["error", "single"], 25 | "semi": ["error", "always"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /strapi/src/admin/app.example.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | locales: [ 3 | // 'ar', 4 | // 'fr', 5 | // 'cs', 6 | // 'de', 7 | // 'dk', 8 | // 'es', 9 | // 'he', 10 | // 'id', 11 | // 'it', 12 | // 'ja', 13 | // 'ko', 14 | // 'ms', 15 | // 'nl', 16 | // 'no', 17 | // 'pl', 18 | // 'pt-BR', 19 | // 'pt', 20 | // 'ru', 21 | // 'sk', 22 | // 'sv', 23 | // 'th', 24 | // 'tr', 25 | // 'uk', 26 | // 'vi', 27 | // 'zh-Hans', 28 | // 'zh', 29 | ], 30 | }; 31 | 32 | const bootstrap = (app) => { 33 | console.log(app); 34 | }; 35 | 36 | export default { 37 | config, 38 | bootstrap, 39 | }; 40 | -------------------------------------------------------------------------------- /strapi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strapi", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "A Strapi application", 6 | "scripts": { 7 | "develop": "strapi develop", 8 | "start": "strapi start", 9 | "build": "strapi build", 10 | "strapi": "strapi" 11 | }, 12 | "devDependencies": {}, 13 | "dependencies": { 14 | "@strapi/plugin-graphql": "^4.14.4", 15 | "@strapi/plugin-i18n": "4.14.4", 16 | "@strapi/plugin-users-permissions": "4.14.4", 17 | "@strapi/strapi": "4.14.4", 18 | "better-sqlite3": "8.6.0" 19 | }, 20 | "author": { 21 | "name": "A Strapi developer" 22 | }, 23 | "strapi": { 24 | "uuid": "78ac33ac-80ec-4ccd-add5-ca321c8c5f43" 25 | }, 26 | "engines": { 27 | "node": ">=16.0.0 <=20.x.x", 28 | "npm": ">=6.0.0" 29 | }, 30 | "license": "MIT" 31 | } 32 | -------------------------------------------------------------------------------- /src/filters/liquid.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { jest } from '@jest/globals' 6 | import { testFields } from '../test-data' 7 | import { isDate, isString } from './liquid' 8 | 9 | // FIXME: Workaround to avoid import of lit-html which breakes unit tests 10 | jest.mock('lit', () => ({ 11 | html: jest.fn(), 12 | render: jest.fn(), 13 | })) 14 | 15 | test('is string', () => { 16 | expect(isString(null)).toBe(false) 17 | expect(isString(testFields.stringField1)).toBe(true) 18 | expect(isString(testFields.dateField1)).toBe(false) 19 | }) 20 | 21 | test('is date', () => { 22 | expect(isDate(null)).toBe(false) 23 | expect(isDate(testFields.stringField1)).toBe(false) 24 | expect(isDate(testFields.dateField1)).toBe(true) 25 | expect(isDate(testFields.dateField2, false)).toBe(true) 26 | expect(isDate(testFields.dateField2)).toBe(false) 27 | }) 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["jest", "node"], 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "target": "es6", 7 | "lib": [ 8 | "dom", 9 | "dom.iterable", 10 | "esnext" 11 | ], 12 | "allowJs": true, 13 | "sourceMap": true, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true, 16 | "strict": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": false, 24 | "emitDecoratorMetadata": true, 25 | "experimentalDecorators": true, 26 | }, 27 | "buildOptions": { 28 | "rootDir": "./src", 29 | "outDir": "./dist", 30 | "baseUrl": "./", 31 | "paths": { 32 | "@/*": ["src/*"] 33 | } 34 | }, 35 | "include": ["src/**/*.ts"], 36 | "exclude": [ 37 | "node_modules/@types/mocha", 38 | "./node_modules/**/*", 39 | "./dist/**/*", 40 | "./old/**/*", 41 | "./__mocks__/**/*", 42 | "node_modules", 43 | "dist", 44 | "old", 45 | "__mocks__", 46 | "**/*.test.ts" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write 13 | contents: read 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: '20' 22 | registry-url: 'https://registry.npmjs.org' 23 | 24 | - name: Update npm 25 | run: npm install -g npm@latest 26 | 27 | - run: npm ci 28 | - run: npm run lint --if-present 29 | - run: npm run build --if-present 30 | - run: npm test --if-present 31 | 32 | - name: Extract version and tag 33 | id: version 34 | run: | 35 | VERSION=$(node -p "require('./package.json').version") 36 | if [[ "$VERSION" == *-* ]]; then 37 | echo "tag=prerelease" >> $GITHUB_OUTPUT 38 | else 39 | echo "tag=latest" >> $GITHUB_OUTPUT 40 | fi 41 | echo "version=$VERSION" >> $GITHUB_OUTPUT 42 | 43 | - name: Publish to npm 44 | run: npm publish --tag ${{ steps.version.outputs.tag }} --access public -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import globals from "globals"; 3 | import tsParser from "@typescript-eslint/parser"; 4 | import path from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | import js from "@eslint/js"; 7 | import { FlatCompat } from "@eslint/eslintrc"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all 15 | }) 16 | 17 | export default [ 18 | ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), 19 | { 20 | plugins: { 21 | "@typescript-eslint": typescriptEslint, 22 | }, 23 | 24 | languageOptions: { 25 | globals: { 26 | ...globals.browser, 27 | ...globals.node, 28 | }, 29 | 30 | parser: tsParser, 31 | ecmaVersion: 5, 32 | sourceType: "module", 33 | }, 34 | 35 | rules: { 36 | indent: ["error", 2], 37 | "linebreak-style": ["error", "unix"], 38 | quotes: ["error", "single"], 39 | semi: ["error", "never"], 40 | "@typescript-eslint/no-unused-expressions": ["error", { 41 | allowShortCircuit: true, 42 | allowTernary: true, 43 | }], 44 | "comma-dangle": ["error", "always-multiline"], 45 | }, 46 | }, 47 | ] 48 | -------------------------------------------------------------------------------- /integration-tests/modules.html: -------------------------------------------------------------------------------- 1 |
2 |

Home

3 |
4 |

simple-hero

5 |
6 |
7 |

special-heading

8 |
9 |
10 |

special-heading

11 |
12 |
13 |

special-heading

14 |
15 |
16 |

section-squares

17 |
18 |
19 |

section-squares

20 |
21 |
22 |

special-heading

23 |
24 |
25 |
26 |

Help and support

27 |
28 |

simple-hero

29 |
30 |
31 |

special-heading

32 |
33 |
34 |

special-heading

35 |
36 |
37 |

section-squares

38 |
39 |
40 |

special-heading

41 |
42 |
43 |

special-heading

44 |
45 |
46 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import GraphQL, { GraphQLOptions } from "./datasources/GraphQL" 2 | import { IDataSource } from "./types" 3 | import { resetDataSources, refreshDataSources } from "./model/dataSourceManager" 4 | import { getAllDataSources, addDataSource } from "./model/dataSourceRegistry" 5 | import { Editor } from "grapesjs" 6 | 7 | export default (editor: Editor) => { 8 | // Save and load data sources 9 | editor.on('storage:start:store', (data: any) => { 10 | data.dataSources = getAllDataSources() 11 | .filter((ds: IDataSource) => typeof ds.readonly === 'undefined' || ds.readonly === false) 12 | .map((ds: IDataSource) => ({ 13 | id: ds.id, 14 | label: ds.label, 15 | url: ds.url, 16 | type: ds.type, 17 | method: ds.method, 18 | headers: ds.headers, 19 | readonly: ds.readonly, 20 | hidden: ds.hidden 21 | })) 22 | }) 23 | 24 | editor.on('storage:end:load', async (data: { dataSources: GraphQLOptions[] }) => { 25 | // Connect the data sources 26 | const newDataSources: IDataSource[] = (data.dataSources || [] as GraphQLOptions[]) 27 | .map((ds: GraphQLOptions) => new GraphQL(ds)) 28 | 29 | // Get all data sources 30 | const dataSources = getAllDataSources() 31 | // Keep only data sources from the config 32 | .filter((ds: IDataSource) => ds.readonly === true) 33 | 34 | // Reset the data sources to the original config 35 | resetDataSources(dataSources) 36 | 37 | // Add the new data sources 38 | newDataSources.forEach(ds => { 39 | addDataSource(ds) 40 | }) 41 | 42 | refreshDataSources() 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /strapi/.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # OS X 3 | ############################ 4 | 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | Icon 9 | .Spotlight-V100 10 | .Trashes 11 | ._* 12 | 13 | 14 | ############################ 15 | # Linux 16 | ############################ 17 | 18 | *~ 19 | 20 | 21 | ############################ 22 | # Windows 23 | ############################ 24 | 25 | Thumbs.db 26 | ehthumbs.db 27 | Desktop.ini 28 | $RECYCLE.BIN/ 29 | *.cab 30 | *.msi 31 | *.msm 32 | *.msp 33 | 34 | 35 | ############################ 36 | # Packages 37 | ############################ 38 | 39 | *.7z 40 | *.csv 41 | *.dat 42 | *.dmg 43 | *.gz 44 | *.iso 45 | *.jar 46 | *.rar 47 | *.tar 48 | *.zip 49 | *.com 50 | *.class 51 | *.dll 52 | *.exe 53 | *.o 54 | *.seed 55 | *.so 56 | *.swo 57 | *.swp 58 | *.swn 59 | *.swm 60 | *.out 61 | *.pid 62 | 63 | 64 | ############################ 65 | # Logs and databases 66 | ############################ 67 | 68 | .tmp 69 | *.log 70 | *.sql 71 | *.sqlite 72 | *.sqlite3 73 | 74 | 75 | ############################ 76 | # Misc. 77 | ############################ 78 | 79 | *# 80 | ssl 81 | .idea 82 | nbproject 83 | public/uploads/* 84 | !public/uploads/.gitkeep 85 | 86 | ############################ 87 | # Node.js 88 | ############################ 89 | 90 | lib-cov 91 | lcov.info 92 | pids 93 | logs 94 | results 95 | node_modules 96 | .node_history 97 | 98 | ############################ 99 | # Tests 100 | ############################ 101 | 102 | coverage 103 | 104 | ############################ 105 | # Strapi 106 | ############################ 107 | 108 | .env 109 | license.txt 110 | exports 111 | *.cache 112 | dist 113 | build 114 | .strapi-updater.json 115 | -------------------------------------------------------------------------------- /integration-tests/base/website.json: -------------------------------------------------------------------------------- 1 | { 2 | "dataSources": [], 3 | "assets": [], 4 | "styles": [ 5 | { 6 | "selectors": [ 7 | "#i0f6" 8 | ], 9 | "style": {} 10 | }, 11 | { 12 | "selectors": [ 13 | "#ikha" 14 | ], 15 | "style": {} 16 | }, 17 | { 18 | "selectors": [ 19 | "#izij" 20 | ], 21 | "wrapper": 1, 22 | "style": {} 23 | } 24 | ], 25 | "pages": [ 26 | { 27 | "frames": [ 28 | { 29 | "component": { 30 | "type": "wrapper", 31 | "stylable": [ 32 | "background", 33 | "background-color", 34 | "background-image", 35 | "background-repeat", 36 | "background-attachment", 37 | "background-position", 38 | "background-size" 39 | ], 40 | "attributes": { 41 | "id": "imnu" 42 | }, 43 | "components": [ 44 | { 45 | "attributes": { 46 | "id": "i0f6" 47 | }, 48 | "components": [ 49 | { 50 | "tagName": "h1", 51 | "type": "text", 52 | "attributes": { 53 | "id": "ikha" 54 | }, 55 | "components": [ 56 | { 57 | "type": "textnode", 58 | "content": "About this demo" 59 | } 60 | ] 61 | } 62 | ] 63 | } 64 | ], 65 | "head": { 66 | "type": "head" 67 | }, 68 | "docEl": { 69 | "tagName": "html" 70 | } 71 | }, 72 | "id": "CWkwORyYHAzFZcwo" 73 | } 74 | ], 75 | "id": "yrhlf8hT03KZ8BbD" 76 | } 77 | ], 78 | "symbols": [] 79 | } 80 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { cleanStateName, concatWithLength } from './utils' 3 | 4 | // FIXME: Workaround to avoid import of lit-html which breakes unit tests 5 | jest.mock('lit', () => ({ 6 | html: jest.fn(), 7 | render: jest.fn(), 8 | })) 9 | 10 | test('test name fall new state', () => { 11 | expect(cleanStateName(null)).toBeUndefined() 12 | expect(cleanStateName('')).toBe('') 13 | expect(cleanStateName('New State')).toBe('new-state') 14 | expect(cleanStateName('1-+ eée')).toBe('----e-e') 15 | expect(cleanStateName('-e-')).toBe('-e-') 16 | expect(cleanStateName('e:e')).toBe('e:e') 17 | expect(cleanStateName('--test')).toBe('--test') 18 | // Bug fix: "0" should not be replaced with "-" 19 | expect(cleanStateName('0ab')).toBe('-ab') 20 | expect(cleanStateName('a0b')).toBe('a0b') 21 | expect(cleanStateName('ab0')).toBe('ab0') 22 | expect(cleanStateName('0ab-')).toBe('-ab-') 23 | expect(cleanStateName('a-0-b')).toBe('a-0-b') 24 | expect(cleanStateName('a-b-0')).toBe('a-b-0') 25 | expect(cleanStateName('a 0 b')).toBe('a-0-b') 26 | expect(cleanStateName('a b 0')).toBe('a-b-0') 27 | expect(cleanStateName('-a b 0-')).toBe('-a-b-0-') 28 | // Test special chars: -, _, ., : 29 | expect(cleanStateName('test-name')).toBe('test-name') 30 | expect(cleanStateName('test_name')).toBe('test_name') 31 | expect(cleanStateName('test.name')).toBe('test.name') 32 | expect(cleanStateName('test:name')).toBe('test:name') 33 | expect(cleanStateName('test-_.:')).toBe('test-_.:') 34 | expect(cleanStateName('a-b_c.d:e')).toBe('a-b_c.d:e') 35 | }) 36 | 37 | test('Test string length', () => { 38 | expect(concatWithLength(5, 'a')).toHaveLength(5) 39 | expect(concatWithLength(5, 'a', 'b')).toHaveLength(5) 40 | expect(concatWithLength(5, 'a', 'b', 'c')).toHaveLength(5) 41 | expect(concatWithLength(5, 'aa', 'bbb')).toHaveLength(5) 42 | expect(concatWithLength(5, 'aaa', 'bb')).toHaveLength(5) 43 | expect(concatWithLength(5, 'a ', 'b')).toHaveLength(5) 44 | expect(concatWithLength(5, 'aaa', 'bbb')).toHaveLength(6) 45 | expect(concatWithLength(5, ' a ', ' b ')).toHaveLength(6) 46 | }) 47 | -------------------------------------------------------------------------------- /strapi/README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Getting started with Strapi 2 | 3 | Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds. 4 | 5 | ### `develop` 6 | 7 | Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop) 8 | 9 | ``` 10 | npm run develop 11 | # or 12 | yarn develop 13 | ``` 14 | 15 | ### `start` 16 | 17 | Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start) 18 | 19 | ``` 20 | npm run start 21 | # or 22 | yarn start 23 | ``` 24 | 25 | ### `build` 26 | 27 | Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build) 28 | 29 | ``` 30 | npm run build 31 | # or 32 | yarn build 33 | ``` 34 | 35 | ## ⚙️ Deployment 36 | 37 | Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case. 38 | 39 | ## 📚 Learn more 40 | 41 | - [Resource center](https://strapi.io/resource-center) - Strapi resource center. 42 | - [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation. 43 | - [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community. 44 | - [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community. 45 | - [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements. 46 | 47 | Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome! 48 | 49 | ## ✨ Community 50 | 51 | - [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team. 52 | - [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members. 53 | - [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi. 54 | 55 | --- 56 | 57 | 🤫 Psst! [Strapi is hiring](https://strapi.io/careers). 58 | -------------------------------------------------------------------------------- /src/datasources/graphql-introspection-query.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | query IntrospectionQuery { 3 | __schema { 4 | queryType { 5 | name 6 | } 7 | types { 8 | ...FullType 9 | } 10 | } 11 | } 12 | fragment FullType on __Type { 13 | kind 14 | name 15 | description 16 | fields(includeDeprecated: true) { 17 | name 18 | description 19 | args { 20 | ...InputValue 21 | } 22 | type { 23 | ...TypeRef 24 | } 25 | isDeprecated 26 | deprecationReason 27 | } 28 | inputFields { 29 | ...InputValue 30 | } 31 | interfaces { 32 | ...TypeRef 33 | } 34 | enumValues(includeDeprecated: true) { 35 | name 36 | description 37 | isDeprecated 38 | deprecationReason 39 | } 40 | possibleTypes { 41 | ...TypeRef 42 | } 43 | } 44 | fragment InputValue on __InputValue { 45 | name 46 | description 47 | type { 48 | ...TypeRef 49 | } 50 | defaultValue 51 | } 52 | fragment TypeRef on __Type { 53 | kind 54 | name 55 | possibleTypes { 56 | kind 57 | name 58 | } 59 | ofType { 60 | kind 61 | name 62 | possibleTypes { 63 | kind 64 | name 65 | } 66 | ofType { 67 | kind 68 | name 69 | possibleTypes { 70 | kind 71 | name 72 | } 73 | ofType { 74 | kind 75 | name 76 | possibleTypes { 77 | kind 78 | name 79 | } 80 | ofType { 81 | kind 82 | name 83 | possibleTypes { 84 | kind 85 | name 86 | } 87 | ofType { 88 | kind 89 | name 90 | possibleTypes { 91 | kind 92 | name 93 | } 94 | ofType { 95 | kind 96 | name 97 | possibleTypes { 98 | kind 99 | name 100 | } 101 | ofType { 102 | kind 103 | name 104 | possibleTypes { 105 | kind 106 | name 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | ` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@silexlabs/grapesjs-data-source", 3 | "version": "0.1.1", 4 | "description": "Grapesjs Data Source", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/cjs/index.js" 11 | }, 12 | "files": [ 13 | "dist", 14 | "src", 15 | "README.md", 16 | "LICENSE" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/silexlabs/grapesjs-data-source.git" 21 | }, 22 | "scripts": { 23 | "start": "$npm_execpath run serve & grapesjs-cli serve", 24 | "build": "grapesjs-cli build --patch=false && tsc -p tsconfig.cjs.json", 25 | "serve": "$npm_execpath run serve:mocks & $npm_execpath run serve:grapesjs", 26 | "serve:mocks": "http-serve ./__mocks__/ -p 3000 --cors", 27 | "serve:grapesjs": "http-serve `node_modules grapesjs`/grapesjs/dist -p 3001 --cors", 28 | "lint": "eslint src/**/*", 29 | "test": "node --experimental-vm-modules `node_modules jest`/.bin/jest --runInBand --no-cache", 30 | "test:watch": "npm test -- --watch", 31 | "prepare": "husky", 32 | "lint:integration:tests": "tidy -m -i -wrap 0 --show-body-only yes integration-tests/**/*.html & find integration-tests -type f -name '*.json' -print0 | xargs -0 -n1 sh -c 'jq . \"$1\" | sponge \"$1\"' sh" 33 | }, 34 | "keywords": [ 35 | "silex", 36 | "grapesjs", 37 | "grapesjs-plugin", 38 | "plugin", 39 | "silex" 40 | ], 41 | "devDependencies": { 42 | "@babel/preset-typescript": "^7.27.0", 43 | "@eslint/eslintrc": "^3.3.1", 44 | "@eslint/js": "^9.25.0", 45 | "@jest/globals": "^29.7.0", 46 | "@types/jest": "^29.5.14", 47 | "@typescript-eslint/eslint-plugin": "^8.30.1", 48 | "@typescript-eslint/parser": "^8.30.1", 49 | "dom-compare": "^0.6.0", 50 | "eslint": "^9.25.0", 51 | "globals": "^16.0.0", 52 | "grapesjs-cli": "^4.1.3", 53 | "http-serve": "^1.0.1", 54 | "husky": "^9.1.7", 55 | "jest": "^29.7.0", 56 | "jest-environment-jsdom": "^29.7.0", 57 | "jsdom": "^26.1.0", 58 | "node_modules-path": "^2.2.0", 59 | "ts-jest": "^29.3.2", 60 | "typescript": "^5.8.3", 61 | "typescript-eslint": "^8.30.1" 62 | }, 63 | "peerDependencies": { 64 | "grapesjs": ">=0.19.0 <0.23.0", 65 | "lit-html": "*" 66 | }, 67 | "author": { 68 | "name": "Alex Hoyau", 69 | "url": "https://lexoyo.me/" 70 | }, 71 | "license": "AGPL-3.0", 72 | "dependencies": { 73 | "@apollo/client": "^3.13.8", 74 | "@silexlabs/expression-input": "0.3.0", 75 | "dedent-js": "^1.0.1" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /integration-tests/visibility-truthy/preview.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Africa

4 |

=======================

5 |

This plugin lets you set "states" on components with the data coming from the API

6 |
7 |
8 |

Antarctica

9 |

This plugin lets you set "states" on components with the data coming from the API

10 |
11 |
12 |

Asia

13 |

This plugin lets you set "states" on components with the data coming from the API

14 |
15 |
16 |

Europe

17 |

This plugin lets you set "states" on components with the data coming from the API

18 |
19 |
20 |

North America

21 |

This plugin lets you set "states" on components with the data coming from the API

22 |
23 |
24 |

Oceania

25 |

This plugin lets you set "states" on components with the data coming from the API

26 |
27 |
28 |

South America

29 |

This plugin lets you set "states" on components with the data coming from the API

30 |
31 |
32 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataSourceEditorOptions, 3 | COMMAND_REFRESH, 4 | COMMAND_PREVIEW_ACTIVATE, 5 | COMMAND_PREVIEW_DEACTIVATE, 6 | COMMAND_PREVIEW_REFRESH, 7 | COMMAND_PREVIEW_TOGGLE, 8 | PREVIEW_ACTIVATED, 9 | PREVIEW_DEACTIVATED, 10 | } from './types' 11 | import { refreshDataSources } from './model/dataSourceManager' 12 | import { Editor } from 'grapesjs' 13 | import { doRender, restoreOriginalRender } from './view/canvas' 14 | 15 | // Global state for preview activation 16 | let isPreviewActive = true 17 | 18 | export function getPreviewActive(): boolean { 19 | return isPreviewActive 20 | } 21 | 22 | // Function to force GrapesJS to re-render all components 23 | function forceRender(editor: Editor) { 24 | // Force a complete re-render by refreshing the canvas 25 | doRender(editor) 26 | } 27 | 28 | // GrapesJS plugin to add commands to the editor 29 | export default (editor: Editor, opts: DataSourceEditorOptions) => { 30 | // Set initial preview state 31 | isPreviewActive = opts.previewActive 32 | 33 | // Refresh all data sources 34 | editor.Commands.add(COMMAND_REFRESH, { 35 | run() { 36 | refreshDataSources() 37 | }, 38 | }) 39 | 40 | // Activate preview mode 41 | editor.Commands.add(COMMAND_PREVIEW_ACTIVATE, { 42 | run() { 43 | if (!isPreviewActive) { 44 | isPreviewActive = true 45 | // Force GrapesJS to re-render to show preview data 46 | forceRender(editor) 47 | // Emit event 48 | editor.trigger(PREVIEW_ACTIVATED) 49 | } 50 | }, 51 | }) 52 | 53 | // Deactivate preview mode 54 | editor.Commands.add(COMMAND_PREVIEW_DEACTIVATE, { 55 | run() { 56 | if (isPreviewActive) { 57 | isPreviewActive = false 58 | // Force GrapesJS to re-render to show original content 59 | const main = editor.Pages.getSelected()?.getMainComponent() 60 | if (main) restoreOriginalRender(main) 61 | // Emit event 62 | editor.trigger(PREVIEW_DEACTIVATED) 63 | } 64 | }, 65 | }) 66 | 67 | // Toggle preview mode 68 | editor.Commands.add(COMMAND_PREVIEW_TOGGLE, { 69 | run() { 70 | isPreviewActive = !isPreviewActive 71 | // Emit event 72 | if (isPreviewActive) { 73 | // Force GrapesJS to re-render to reflect the toggled state 74 | forceRender(editor) 75 | // Trigger event 76 | editor.trigger(PREVIEW_ACTIVATED) 77 | } else { 78 | // Force GrapesJS to re-render to show original content 79 | const main = editor.Pages.getSelected()?.getMainComponent() 80 | if (main) restoreOriginalRender(main) 81 | // Trigger event 82 | editor.trigger(PREVIEW_DEACTIVATED) 83 | } 84 | }, 85 | }) 86 | 87 | // Refresh preview data 88 | editor.Commands.add(COMMAND_PREVIEW_REFRESH, { 89 | run() { 90 | if (isPreviewActive) { 91 | forceRender(editor) 92 | } 93 | }, 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /strapi/config/database.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = ({ env }) => { 4 | const client = env('DATABASE_CLIENT', 'sqlite'); 5 | 6 | const connections = { 7 | mysql: { 8 | connection: { 9 | connectionString: env('DATABASE_URL'), 10 | host: env('DATABASE_HOST', 'localhost'), 11 | port: env.int('DATABASE_PORT', 3306), 12 | database: env('DATABASE_NAME', 'strapi'), 13 | user: env('DATABASE_USERNAME', 'strapi'), 14 | password: env('DATABASE_PASSWORD', 'strapi'), 15 | ssl: env.bool('DATABASE_SSL', false) && { 16 | key: env('DATABASE_SSL_KEY', undefined), 17 | cert: env('DATABASE_SSL_CERT', undefined), 18 | ca: env('DATABASE_SSL_CA', undefined), 19 | capath: env('DATABASE_SSL_CAPATH', undefined), 20 | cipher: env('DATABASE_SSL_CIPHER', undefined), 21 | rejectUnauthorized: env.bool( 22 | 'DATABASE_SSL_REJECT_UNAUTHORIZED', 23 | true 24 | ), 25 | }, 26 | }, 27 | pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, 28 | }, 29 | mysql2: { 30 | connection: { 31 | host: env('DATABASE_HOST', 'localhost'), 32 | port: env.int('DATABASE_PORT', 3306), 33 | database: env('DATABASE_NAME', 'strapi'), 34 | user: env('DATABASE_USERNAME', 'strapi'), 35 | password: env('DATABASE_PASSWORD', 'strapi'), 36 | ssl: env.bool('DATABASE_SSL', false) && { 37 | key: env('DATABASE_SSL_KEY', undefined), 38 | cert: env('DATABASE_SSL_CERT', undefined), 39 | ca: env('DATABASE_SSL_CA', undefined), 40 | capath: env('DATABASE_SSL_CAPATH', undefined), 41 | cipher: env('DATABASE_SSL_CIPHER', undefined), 42 | rejectUnauthorized: env.bool( 43 | 'DATABASE_SSL_REJECT_UNAUTHORIZED', 44 | true 45 | ), 46 | }, 47 | }, 48 | pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, 49 | }, 50 | postgres: { 51 | connection: { 52 | connectionString: env('DATABASE_URL'), 53 | host: env('DATABASE_HOST', 'localhost'), 54 | port: env.int('DATABASE_PORT', 5432), 55 | database: env('DATABASE_NAME', 'strapi'), 56 | user: env('DATABASE_USERNAME', 'strapi'), 57 | password: env('DATABASE_PASSWORD', 'strapi'), 58 | ssl: env.bool('DATABASE_SSL', false) && { 59 | key: env('DATABASE_SSL_KEY', undefined), 60 | cert: env('DATABASE_SSL_CERT', undefined), 61 | ca: env('DATABASE_SSL_CA', undefined), 62 | capath: env('DATABASE_SSL_CAPATH', undefined), 63 | cipher: env('DATABASE_SSL_CIPHER', undefined), 64 | rejectUnauthorized: env.bool( 65 | 'DATABASE_SSL_REJECT_UNAUTHORIZED', 66 | true 67 | ), 68 | }, 69 | schema: env('DATABASE_SCHEMA', 'public'), 70 | }, 71 | pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, 72 | }, 73 | sqlite: { 74 | connection: { 75 | filename: path.join( 76 | __dirname, 77 | '..', 78 | env('DATABASE_FILENAME', '.tmp/data.db') 79 | ), 80 | }, 81 | useNullAsDefault: true, 82 | }, 83 | }; 84 | 85 | return { 86 | connection: { 87 | client, 88 | ...connections[client], 89 | acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000), 90 | }, 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/filters/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Liquid Filter Engine 3 | * Centralized management of Liquid template filters 4 | */ 5 | 6 | import { Editor } from 'grapesjs' 7 | import { DataSourceEditorOptions, Filter } from '../types' 8 | import getLiquidFilters from './liquid' 9 | 10 | /** 11 | * Liquid Engine interface for managing and applying filters 12 | */ 13 | export interface LiquidEngine { 14 | readonly filters: ReadonlyMap 15 | applyFilter: (value: unknown, filterId: string, options: Record) => unknown 16 | hasFilter: (filterId: string) => boolean 17 | } 18 | 19 | /** 20 | * Create a Liquid engine with the given filters 21 | */ 22 | export function createLiquidEngine(filters: readonly Filter[]): LiquidEngine { 23 | const filterMap = new Map(filters.map(f => [f.id, f])) 24 | 25 | return { 26 | filters: filterMap, 27 | 28 | applyFilter(value: unknown, filterId: string, options: Record): unknown { 29 | const filter = filterMap.get(filterId) 30 | if (!filter) { 31 | throw new Error(`Liquid filter not found: ${filterId}`) 32 | } 33 | return filter.apply(value, options) 34 | }, 35 | 36 | hasFilter(filterId: string): boolean { 37 | return filterMap.has(filterId) 38 | }, 39 | } 40 | } 41 | 42 | /** 43 | * Validate that required filter fields are present 44 | */ 45 | export function validateFilter(filter: Filter): void { 46 | if (!filter.id) throw new Error('Filter id is required') 47 | if (!filter.label) throw new Error('Filter label is required') 48 | if (!filter.validate) throw new Error('Filter validate is required') 49 | if (!filter.output) throw new Error('Filter output is required') 50 | if (!filter.apply) throw new Error('Filter apply is required') 51 | } 52 | 53 | /** 54 | * Validate multiple filters 55 | */ 56 | export function validateFilters(filters: readonly Filter[]): void { 57 | filters.forEach(validateFilter) 58 | } 59 | 60 | /** 61 | * Add filters to an existing engine 62 | */ 63 | export function addFiltersToEngine(engine: LiquidEngine, filters: readonly Filter[]): LiquidEngine { 64 | validateFilters(filters) 65 | const allFilters = [...engine.filters.values(), ...filters] 66 | return createLiquidEngine(allFilters) 67 | } 68 | 69 | /** 70 | * Remove filters from an existing engine 71 | */ 72 | export function removeFiltersFromEngine(engine: LiquidEngine, filterIds: readonly string[]): LiquidEngine { 73 | const remaining = [...engine.filters.values()].filter(f => !filterIds.includes(f.id)) 74 | return createLiquidEngine(remaining) 75 | } 76 | 77 | /** 78 | * Initialize filters from options 79 | */ 80 | export function initializeFilters(editor: Editor, options: DataSourceEditorOptions): Filter[] { 81 | if (typeof options.filters === 'string') { 82 | return [ 83 | ...getLiquidFilters(editor), 84 | ] 85 | } else { 86 | return (options.filters as Filter[]) 87 | .flatMap((filter: Partial | string): Filter[] => { 88 | if (typeof filter === 'string') { 89 | switch (filter) { 90 | case 'liquid': return getLiquidFilters(editor) 91 | default: throw new Error(`Unknown filters ${filter}`) 92 | } 93 | } else { 94 | return [{ 95 | ...filter as Partial, 96 | type: 'filter', 97 | } as Filter] 98 | } 99 | }) 100 | .map((filter: Filter) => ({ ...filter, type: 'filter' })) as Filter[] 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import commands from './commands' 19 | import { initializeDataSourceManager, refreshDataSources } from './model/dataSourceManager' 20 | import storage from './storage' 21 | import { DATA_SOURCE_ERROR, DataSourceEditorOptions, IDataSource, IDataSourceOptions } from './types' 22 | import { createDataSource, NOTIFICATION_GROUP } from './utils' 23 | import view from './view' 24 | import { Editor } from 'grapesjs' 25 | 26 | /** 27 | * Export the public API 28 | */ 29 | // Main public API - this is what apps should use 30 | export * from './api' 31 | 32 | // Types and interfaces that apps need 33 | export * from './types' 34 | 35 | /** 36 | * GrapeJs plugin entry point 37 | */ 38 | export default (editor: Editor, opts: Partial = {}) => { 39 | const options: DataSourceEditorOptions = { 40 | dataSources: [], 41 | filters: [], 42 | previewActive: true, // Default to preview active 43 | ...opts, 44 | view: { 45 | el: '.gjs-pn-panel.gjs-pn-views-container', 46 | ...opts?.view, 47 | }, 48 | } 49 | 50 | const dataSources = options.dataSources 51 | // Make sure the data sources from the config are readonly 52 | .map(ds => ({ ...ds, readonly: true })) 53 | // Create the data sources from config 54 | .map((ds: IDataSourceOptions) => createDataSource(ds)) 55 | 56 | // Connect the data sources (async) 57 | Promise.all(dataSources 58 | .map(ds => { 59 | return ds.connect() 60 | .catch(err => console.error(`Data source ${ds.id} connection failed:`, err)) 61 | })) 62 | .catch(err => console.error('Error while connecting data sources', err)) 63 | 64 | // Initialize the global data source manager 65 | initializeDataSourceManager(dataSources, editor, options) 66 | 67 | // Register the UI for component properties 68 | view(editor, options) 69 | 70 | // Save and load data sources 71 | storage(editor) 72 | 73 | // Register the commands 74 | commands(editor, options) 75 | 76 | // Use grapesjs-notifications plugin for errors 77 | editor.on(DATA_SOURCE_ERROR, (msg: string, ds: IDataSource) => editor.runCommand('notifications:add', { type: 'error', message: `Data source \`${ds.id}\` error: ${msg}`, group: NOTIFICATION_GROUP })) 78 | 79 | // Load data after editor is fully loaded 80 | editor.on('load', () => { 81 | refreshDataSources() 82 | }) 83 | 84 | // Also refresh data when storage loads (to handle website data loading) 85 | editor.on('storage:end:load', () => { 86 | // Use setTimeout to ensure components are fully loaded 87 | setTimeout(() => { 88 | refreshDataSources() 89 | }, 100) 90 | }) 91 | } 92 | 93 | /** 94 | * Version of the plugin 95 | * This is replaced by the build script 96 | */ 97 | export const version = '__VERSION__' 98 | -------------------------------------------------------------------------------- /src/model/dataSourceRegistry.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { DataSourceId, IDataSource, DATA_SOURCE_CHANGED } from '../types' 19 | import { Editor } from 'grapesjs' 20 | 21 | /** 22 | * Data source registry state 23 | */ 24 | interface DataSourceRegistryState { 25 | dataSources: IDataSource[] 26 | editor: Editor 27 | } 28 | 29 | // Global registry instance 30 | let globalRegistry: DataSourceRegistryState | null = null 31 | 32 | /** 33 | * Initialize the data source registry 34 | */ 35 | export function initializeDataSourceRegistry(editor: Editor): void { 36 | globalRegistry = { 37 | dataSources: [], 38 | editor, 39 | } 40 | } 41 | 42 | /** 43 | * Get the global registry (throws if not initialized) 44 | */ 45 | function getRegistry(): DataSourceRegistryState { 46 | if (!globalRegistry) { 47 | throw new Error('DataSourceRegistry not initialized. Call initializeDataSourceRegistry first.') 48 | } 49 | return globalRegistry 50 | } 51 | 52 | /** 53 | * Get all data sources 54 | */ 55 | export function getAllDataSources(): IDataSource[] { 56 | return [...getRegistry().dataSources] 57 | } 58 | 59 | /** 60 | * Add a data source 61 | */ 62 | export function addDataSource(dataSource: IDataSource): void { 63 | const registry = getRegistry() 64 | registry.dataSources.push(dataSource) 65 | dataSource.connect() 66 | .then(() => { 67 | registry.editor.trigger(DATA_SOURCE_CHANGED) 68 | }) 69 | .catch((error) => { 70 | console.error('Failed to connect data source:', error) 71 | registry.editor.trigger(DATA_SOURCE_CHANGED) 72 | }) 73 | } 74 | 75 | /** 76 | * Remove a data source 77 | */ 78 | export function removeDataSource(dataSource: IDataSource): void { 79 | const registry = getRegistry() 80 | const index = registry.dataSources.indexOf(dataSource) 81 | if (index > -1) { 82 | registry.dataSources.splice(index, 1) 83 | registry.editor.trigger(DATA_SOURCE_CHANGED) 84 | } 85 | } 86 | 87 | /** 88 | * Get a data source by ID 89 | */ 90 | export function getDataSource(id: DataSourceId): IDataSource | undefined { 91 | return getRegistry().dataSources.find(ds => ds.id === id) 92 | } 93 | 94 | /** 95 | * Set all data sources (replaces existing) 96 | */ 97 | export function setDataSources(dataSources: IDataSource[]): void { 98 | const registry = getRegistry() 99 | registry.dataSources = [...dataSources] 100 | registry.editor.trigger(DATA_SOURCE_CHANGED) 101 | } 102 | 103 | /** 104 | * Convert to JSON for storage 105 | */ 106 | export function dataSourcesToJSON(): unknown[] { 107 | return getRegistry().dataSources.map(ds => ({ 108 | id: ds.id, 109 | label: ds.label, 110 | url: ds.url, 111 | type: ds.type, 112 | method: ds.method, 113 | headers: ds.headers, 114 | readonly: ds.readonly, 115 | hidden: ds.hidden, 116 | })) 117 | } 118 | -------------------------------------------------------------------------------- /src/storage.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { jest } from '@jest/globals' 5 | import { Editor } from 'grapesjs' 6 | import storage from './storage' 7 | import { resetDataSources, refreshDataSources } from './model/dataSourceManager' 8 | import { getAllDataSources, addDataSource } from './model/dataSourceRegistry' 9 | 10 | // FIXME: Workaround to avoid import of lit-html which breakes unit tests 11 | jest.mock('lit', () => ({ 12 | html: jest.fn(), 13 | render: jest.fn(), 14 | })) 15 | 16 | // Mock the dataSourceManager to avoid complex initialization 17 | jest.mock('./model/dataSourceManager', () => ({ 18 | resetDataSources: jest.fn(), 19 | refreshDataSources: jest.fn() 20 | })) 21 | 22 | // Mock the dataSourceRegistry 23 | jest.mock('./model/dataSourceRegistry', () => ({ 24 | getAllDataSources: jest.fn(), 25 | addDataSource: jest.fn(), 26 | })) 27 | 28 | const config1 = { readonly: true, id: 'config1', label: 'Config 1', url: 'http://config1.com', type: 'graphql' as const } // from the config 29 | const config2 = { readonly: true, id: 'config2', label: 'Config 2', url: 'http://config2.com', type: 'graphql' as const } // from the config 30 | const website1 = { readonly: false, id: 'website1', label: 'Website 1', url: 'http://website1.com', type: 'graphql' as const } // from the website 31 | const website2 = { id: 'website2', label: 'Website 2', url: 'http://website2.com', type: 'graphql' as const } // from the website (readonly undefined) 32 | jest.mock('./datasources/GraphQL', () => jest.fn().mockImplementation((x) => ({ connect: jest.fn(), id: 'website1DataSource', ...x }))) 33 | 34 | describe('storage', () => { 35 | let editor: Editor 36 | let onStore: Function 37 | let onLoad: Function 38 | beforeEach(() => { 39 | const callbacks = new Map() 40 | editor = { 41 | on: (event, callback) => callbacks.set(event, callback), 42 | } as any as Editor 43 | 44 | // Mock the getAllDataSources to return our test data 45 | ;(getAllDataSources as jest.Mock).mockReturnValue([config1, config2, website1, website2]) 46 | 47 | storage(editor) 48 | expect(callbacks.get('storage:start:store')).toBeDefined() 49 | onStore = callbacks.get('storage:start:store')! 50 | expect(callbacks.get('storage:end:load')).toBeDefined() 51 | onLoad = callbacks.get('storage:end:load')! 52 | }) 53 | 54 | it('should store data sources from the website only', () => { 55 | const previousDataSource = { id: 'whatever data'} 56 | const data = { dataSources: [previousDataSource] } 57 | onStore(data) 58 | expect(data.dataSources).toHaveLength(2) 59 | expect(data.dataSources[0]).not.toEqual(previousDataSource) 60 | expect(data.dataSources[0].id).toEqual('website1') 61 | expect(data.dataSources[1].id).toEqual('website2') 62 | }) 63 | it('should load data sources from website and keep those from the config', async () => { 64 | const newWebsite1 = { readonly: false, id: 'newWebsite1', label: 'New Website 1', url: 'http://new1.com', type: 'graphql' as const } 65 | const newWebsite2 = { id: 'newWebsite2', label: 'New Website 2', url: 'http://new2.com', type: 'graphql' as const } 66 | const newWebsite3 = { readonly: true, id: 'newWebsite3', label: 'New Website 3', url: 'http://new3.com', type: 'graphql' as const } 67 | 68 | const data = { dataSources: [ 69 | newWebsite1, 70 | newWebsite2, 71 | newWebsite3, 72 | ] } 73 | await onLoad(data) 74 | 75 | // Check that resetDataSources was called with config data sources only 76 | expect(resetDataSources).toHaveBeenCalledWith([config1, config2]) 77 | 78 | // Check that addDataSource was called for each new data source 79 | expect(addDataSource).toHaveBeenCalledTimes(3) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/model/state.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import grapesjs from 'grapesjs' 5 | import { getChildByPersistantId, getComponentByPersistentId, getParentByPersistentId, getPersistantId, getStateIds, setState } from './state' 6 | import { Editor } from 'grapesjs' 7 | 8 | test('getChildByPersistantId', () => { 9 | const editor = grapesjs.init({ 10 | container: document.createElement('div'), 11 | components: `
12 |
13 |
14 |
15 |
16 |
`, 17 | }) 18 | const parent = editor.Components.getById('parent') 19 | expect(parent).not.toBeNull() 20 | expect(parent.get('attributes')?.id).toBe('parent') 21 | const child1 = editor.Components.getById('child1') 22 | expect(child1).not.toBeNull() 23 | const child2 = editor.Components.getById('child2') 24 | expect(child2).not.toBeNull() 25 | const child3 = editor.Components.getById('child3') 26 | expect(child3).not.toBeNull() 27 | parent.set('id-plugin-data-source', 'test-id-parent') 28 | expect(getPersistantId(parent)).toBe('test-id-parent') 29 | child3.set('id-plugin-data-source', 'test-id-child3') 30 | expect(getPersistantId(child3)).toBe('test-id-child3') 31 | expect(getParentByPersistentId('test-id-parent', child3)).toBe(parent) 32 | expect(getChildByPersistantId('test-id-child3', parent)).toBe(child3) 33 | expect(getComponentByPersistentId('test-id-child3', editor as Editor)).toBe(child3) 34 | expect(getComponentByPersistentId('test-id-parent', editor as Editor)).toBe(parent) 35 | }) 36 | test('getStateIds', () => { 37 | const editor = grapesjs.init({ 38 | container: document.createElement('div'), 39 | components: `
40 |
41 |
42 |
43 |
44 |
`, 45 | }) 46 | const parent = editor.Components.getById('parent') 47 | setState(parent, 'state1', {label: 'State 1', expression: []}) 48 | setState(parent, 'state2', {label: 'State 2', expression: []}, true) 49 | setState(parent, 'state3', {label: 'State 3', expression: []}, false) 50 | setState(parent, 'state4', {label: 'State 4', expression: []}, true) 51 | 52 | expect(getStateIds(parent)).toEqual(['state1', 'state2', 'state4']) 53 | expect(getStateIds(parent, true)).toEqual(['state1', 'state2', 'state4']) 54 | expect(getStateIds(parent, false)).toEqual(['state3']) 55 | expect(getStateIds(parent, true, 'state2')).toEqual(['state1']) 56 | expect(getStateIds(parent, true, 'state4')).toEqual(['state1', 'state2']) 57 | expect(getStateIds(parent, true, 'does not exist')).toEqual(['state1', 'state2', 'state4']) 58 | }) 59 | test('getStateIds with specific index', () => { 60 | const editor = grapesjs.init({ 61 | container: document.createElement('div'), 62 | components: '
', 63 | }) 64 | const parent = editor.Components.getById('parent') 65 | setState(parent, 'state1', {label: 'State 1', expression: []}, true) 66 | setState(parent, 'state2', {label: 'State 2', expression: []}, true) 67 | setState(parent, 'state3', {label: 'State 3', expression: []}, false) 68 | setState(parent, 'state4', {label: 'State 4', expression: []}, true) 69 | setState(parent, 'statePos10', {label: 'State Pos 10', expression: []}, true, 10) 70 | setState(parent, 'statePos0', {label: 'State Pos 0', expression: []}, true, 0) 71 | setState(parent, 'statePos2', {label: 'State Pos 2', expression: []}, true, 2) 72 | setState(parent, 'statePos11', {label: 'State Pos 11', expression: []}, true, 11) 73 | 74 | expect(getStateIds(parent)).toEqual(['statePos0', 'state1', 'statePos2', 'state2', 'state4', 'statePos10', 'statePos11']) 75 | expect(getStateIds(parent, false)).toEqual(['state3']) 76 | }) 77 | -------------------------------------------------------------------------------- /_index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Home 6 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |

About this demo

25 |

For this test I configured the plugin with a demo country API: 26 | https://studio.apollographql.com/public/countries/variant/current/home

27 |

Select a component and check it's settings on the right

28 |

This plugin lets you set "states" on components with the data coming from the API

29 |
30 |
31 | 109 | 110 | -------------------------------------------------------------------------------- /src/test-data.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { DataSourceId, Field, Filter, Token, Type } from './types' 3 | 4 | export async function importDataSource(datas?: unknown[]) { 5 | if (datas?.length) { 6 | global.fetch = jest.fn() as jest.MockedFunction 7 | datas?.forEach(data => { 8 | (global.fetch as jest.MockedFunction) 9 | .mockImplementationOnce(() => { 10 | return Promise.resolve({ 11 | ok: true, 12 | json: () => Promise.resolve(data), 13 | } as Response) 14 | }) 15 | }) 16 | } 17 | return (await import('./datasources/GraphQL')).default 18 | } 19 | 20 | export const testDataSourceId: DataSourceId = 'testDataSourceId' 21 | 22 | export const testTokens: Record = { 23 | rootField1: { 24 | type: 'property', 25 | propType: 'field', 26 | fieldId: 'rootField1', 27 | label: 'test', 28 | typeIds: ['rootTypeId1'], 29 | kind: 'object', 30 | dataSourceId: 'testDataSourceId', 31 | }, 32 | filter: { 33 | type: 'filter', 34 | id: 'filterId', 35 | label: 'filter name', 36 | validate: () => true, 37 | output: () => null, 38 | apply: () => null, 39 | options: {}, 40 | }, 41 | rootField2: { 42 | type: 'property', 43 | propType: 'field', 44 | fieldId: 'rootField2', 45 | label: 'test', 46 | typeIds: ['rootTypeId2'], 47 | kind: 'object', 48 | dataSourceId: 'testDataSourceId', 49 | }, 50 | childField1: { 51 | type: 'property', 52 | propType: 'field', 53 | fieldId: 'childField1', 54 | label: 'test', 55 | typeIds: ['childTypeId1'], 56 | kind: 'scalar', 57 | dataSourceId: 'testDataSourceId', 58 | }, 59 | childField2: { 60 | type: 'property', 61 | propType: 'field', 62 | fieldId: 'childField2', 63 | label: 'test', 64 | typeIds: ['childTypeId2'], 65 | kind: 'scalar', 66 | dataSourceId: 'testDataSourceId', 67 | }, 68 | childField3: { 69 | type: 'property', 70 | propType: 'field', 71 | fieldId: 'childField3', 72 | label: 'test', 73 | typeIds: ['childTypeId3'], 74 | kind: 'scalar', 75 | dataSourceId: 'testDataSourceId', 76 | }, 77 | } 78 | export const testFields: Record = { 79 | stringField1: { 80 | id: 'stringField1', 81 | label: 'test', 82 | typeIds: ['String'], 83 | kind: 'scalar', 84 | dataSourceId: 'testDataSourceId', 85 | }, 86 | dateField1: { 87 | id: 'dateField1', 88 | label: 'test', 89 | typeIds: ['SomeType', 'date'], 90 | kind: 'scalar', 91 | dataSourceId: 'testDataSourceId', 92 | }, 93 | dateField2: { 94 | id: 'dateField2', 95 | label: 'test', 96 | typeIds: ['SomeType', 'Instant'], 97 | kind: 'list', 98 | dataSourceId: 'testDataSourceId', 99 | }, 100 | } 101 | 102 | export const simpleFilters: Filter[] = [{ 103 | type: 'filter', 104 | id: 'testFilterAnyInput', 105 | label: 'test filter any input', 106 | validate: type => !type, // Just for empty expressions 107 | output: () => null, 108 | options: {}, 109 | apply: jest.fn(), 110 | }, { 111 | type: 'filter', 112 | id: 'testFilterId', 113 | label: 'test filter name', 114 | validate: type => !!type?.typeIds.includes('testTypeId'), 115 | output: type => type!, 116 | options: {}, 117 | apply: jest.fn(), 118 | }, { 119 | type: 'filter', 120 | id: 'testFilterId2', 121 | label: 'test filter name 2', 122 | validate: type => !!type?.typeIds.includes('testFieldTypeId'), 123 | output: () => null, 124 | options: {}, 125 | apply: jest.fn(), 126 | }] 127 | 128 | export const simpleTypes: Type[] = [{ 129 | id: 'testTypeId', 130 | label: 'test type name', 131 | fields: [ 132 | { 133 | id: 'testFieldId', 134 | label: 'test field name', 135 | typeIds: ['testFieldTypeId'], 136 | kind: 'scalar', 137 | dataSourceId: testDataSourceId, 138 | } 139 | ], 140 | dataSourceId: testDataSourceId, 141 | }, { 142 | id: 'testFieldTypeId', 143 | label: 'test field type name', 144 | fields: [], 145 | dataSourceId: testDataSourceId, 146 | }] 147 | 148 | export const simpleQueryables: Field[] = [{ 149 | id: 'testSimpleQueryableId', 150 | label: 'test queryable', 151 | typeIds: ['testTypeId'], 152 | kind: 'scalar', 153 | dataSourceId: testDataSourceId, 154 | }] 155 | -------------------------------------------------------------------------------- /src/model/queryBuilder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { DataSourceId, Property, StoredToken, ComponentExpression } from '../types' 19 | import { getPageExpressions, toTrees } from './ExpressionTree' 20 | import { getManager } from './dataSourceManager' 21 | import { getAllDataSources } from './dataSourceRegistry' 22 | import { Page, Editor } from 'grapesjs' 23 | import { getComponentDebug, NOTIFICATION_GROUP } from '../utils' 24 | import { resolveStateExpression } from './expressionEvaluator' 25 | 26 | /** 27 | * Get page query for both preview and production use 28 | * Used by preview system and 11ty site generation 29 | * @param page - The page to get queries for 30 | * @param editor - The GrapesJS editor instance 31 | * @param dataTree - The DataTree instance to use for query generation 32 | */ 33 | export function getPageQuery(page: Page, editor: Editor): Record { 34 | const manager = getManager() 35 | const expressions: ComponentExpression[] = getPageExpressions(manager, page) 36 | const dataSources = getAllDataSources() 37 | 38 | return dataSources 39 | .map(ds => { 40 | if (!ds.isConnected()) { 41 | console.error('The data source is not yet connected, the value for this page can not be loaded') 42 | return { 43 | dataSourceId: ds.id.toString(), 44 | query: '', 45 | } 46 | } 47 | 48 | const dsExpressions = expressions 49 | // Resolve all states 50 | .map((componentExpression: ComponentExpression) => ({ 51 | component: componentExpression.component, 52 | expression: componentExpression.expression.flatMap((token: StoredToken) => { 53 | switch(token.type) { 54 | case 'property': 55 | case 'filter': 56 | return token 57 | case 'state': { 58 | const resolved = resolveStateExpression(token, componentExpression.component, []) 59 | if (!resolved) { 60 | editor.runCommand('notifications:add', { 61 | type: 'error', 62 | group: NOTIFICATION_GROUP, 63 | message: `Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`, 64 | componentId: componentExpression.component.getId(), 65 | }) 66 | throw new Error(`Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`) 67 | } 68 | return resolved 69 | } 70 | } 71 | }), 72 | })) 73 | // Keep only the expressions for the current data source 74 | .filter((componentExpression: ComponentExpression) => { 75 | const e = componentExpression.expression 76 | if(e.length === 0) return false 77 | // We resolved all states 78 | // An expression can not start with a filter 79 | // So this is a property 80 | const first = e[0] as Property 81 | // Keep only the expressions for the current data source 82 | return first?.dataSourceId === ds.id 83 | }) 84 | 85 | const trees = toTrees(manager, dsExpressions, ds.id) 86 | if(trees.length === 0) { 87 | return { 88 | dataSourceId: ds.id.toString(), 89 | query: '', 90 | } 91 | } 92 | 93 | const query = ds.getQuery(trees) 94 | return { 95 | dataSourceId: ds.id.toString(), 96 | query, 97 | } 98 | }) 99 | .filter(obj => !!obj.query) 100 | .reduce((acc, { dataSourceId, query }) => { 101 | acc[dataSourceId] = query 102 | return acc 103 | }, {} as Record) 104 | } 105 | 106 | /** 107 | * Build queries for multiple pages 108 | * Useful for batch operations like site generation 109 | * @param pages - Array of pages to build queries for 110 | * @param editor - The GrapesJS editor instance 111 | * @param dataTree - The DataTree instance to use for query generation 112 | */ 113 | export function buildPageQueries(pages: Page[], editor: Editor): Record> { 114 | return pages.reduce((acc, page) => { 115 | acc[page.getId()] = getPageQuery(page, editor) 116 | return acc 117 | }, {} as Record>) 118 | } 119 | -------------------------------------------------------------------------------- /src/model/dataSourceRegistry.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { jest } from '@jest/globals' 5 | import grapesjs, { Editor } from 'grapesjs' 6 | import { 7 | initializeDataSourceRegistry, 8 | getAllDataSources, 9 | addDataSource, 10 | removeDataSource, 11 | getDataSource, 12 | setDataSources, 13 | dataSourcesToJSON, 14 | } from './dataSourceRegistry' 15 | import { IDataSource } from '../types' 16 | 17 | // FIXME: Workaround to avoid import of lit-html which breaks unit tests 18 | jest.mock('lit', () => ({ 19 | html: jest.fn(), 20 | render: jest.fn(), 21 | })) 22 | 23 | describe('DataSourceRegistry', () => { 24 | let editor: Editor 25 | let mockDataSource1: IDataSource 26 | let mockDataSource2: IDataSource 27 | 28 | beforeEach(() => { 29 | editor = grapesjs.init({ 30 | container: document.createElement('div'), 31 | components: '
', 32 | }) 33 | 34 | mockDataSource1 = { 35 | id: 'test1', 36 | label: 'Test Data Source 1', 37 | url: 'http://test1.com', 38 | type: 'graphql', 39 | method: 'POST', 40 | headers: { 'Content-Type': 'application/json' }, 41 | readonly: false, 42 | hidden: false, 43 | connect: jest.fn(() => Promise.resolve()), 44 | isConnected: jest.fn(() => true), 45 | getTypes: jest.fn(() => []), 46 | getQueryables: jest.fn(() => []), 47 | getQuery: jest.fn(() => ''), 48 | fetchValues: jest.fn(() => Promise.resolve({})), 49 | on: jest.fn(), 50 | off: jest.fn(), 51 | } 52 | 53 | mockDataSource2 = { 54 | id: 'test2', 55 | label: 'Test Data Source 2', 56 | url: 'http://test2.com', 57 | type: 'graphql', 58 | method: 'GET', 59 | headers: {}, 60 | readonly: true, 61 | hidden: false, 62 | connect: jest.fn(() => Promise.resolve()), 63 | isConnected: jest.fn(() => true), 64 | getTypes: jest.fn(() => []), 65 | getQueryables: jest.fn(() => []), 66 | getQuery: jest.fn(() => ''), 67 | fetchValues: jest.fn(() => Promise.resolve({})), 68 | on: jest.fn(), 69 | off: jest.fn(), 70 | } 71 | 72 | initializeDataSourceRegistry(editor) 73 | }) 74 | 75 | it('should initialize with empty data sources', () => { 76 | expect(getAllDataSources()).toHaveLength(0) 77 | }) 78 | 79 | it('should add a data source', () => { 80 | addDataSource(mockDataSource1) 81 | expect(getAllDataSources()).toHaveLength(1) 82 | expect(getAllDataSources()[0]).toBe(mockDataSource1) 83 | }) 84 | 85 | it('should remove a data source', () => { 86 | addDataSource(mockDataSource1) 87 | addDataSource(mockDataSource2) 88 | expect(getAllDataSources()).toHaveLength(2) 89 | 90 | removeDataSource(mockDataSource1) 91 | expect(getAllDataSources()).toHaveLength(1) 92 | expect(getAllDataSources()[0]).toBe(mockDataSource2) 93 | }) 94 | 95 | it('should get a data source by ID', () => { 96 | addDataSource(mockDataSource1) 97 | addDataSource(mockDataSource2) 98 | 99 | expect(getDataSource('test1')).toBe(mockDataSource1) 100 | expect(getDataSource('test2')).toBe(mockDataSource2) 101 | expect(getDataSource('nonexistent')).toBeUndefined() 102 | }) 103 | 104 | it('should set all data sources', () => { 105 | addDataSource(mockDataSource1) 106 | expect(getAllDataSources()).toHaveLength(1) 107 | 108 | setDataSources([mockDataSource2]) 109 | expect(getAllDataSources()).toHaveLength(1) 110 | expect(getAllDataSources()[0]).toBe(mockDataSource2) 111 | }) 112 | 113 | it('should convert data sources to JSON', () => { 114 | addDataSource(mockDataSource1) 115 | addDataSource(mockDataSource2) 116 | 117 | const json = dataSourcesToJSON() 118 | expect(json).toHaveLength(2) 119 | expect(json[0]).toEqual({ 120 | id: 'test1', 121 | label: 'Test Data Source 1', 122 | url: 'http://test1.com', 123 | type: 'graphql', 124 | method: 'POST', 125 | headers: { 'Content-Type': 'application/json' }, 126 | readonly: false, 127 | hidden: false, 128 | }) 129 | expect(json[1]).toEqual({ 130 | id: 'test2', 131 | label: 'Test Data Source 2', 132 | url: 'http://test2.com', 133 | type: 'graphql', 134 | method: 'GET', 135 | headers: {}, 136 | readonly: true, 137 | hidden: false, 138 | }) 139 | }) 140 | 141 | it('should trigger DATA_SOURCE_CHANGED event when adding data source', async () => { 142 | const triggerSpy = jest.spyOn(editor, 'trigger') 143 | addDataSource(mockDataSource1) 144 | 145 | // Wait for the promise to resolve 146 | await new Promise(resolve => setTimeout(resolve, 0)) 147 | 148 | expect(triggerSpy).toHaveBeenCalledWith('data-source:changed') 149 | }) 150 | 151 | it('should trigger DATA_SOURCE_CHANGED event when removing data source', () => { 152 | addDataSource(mockDataSource1) 153 | const triggerSpy = jest.spyOn(editor, 'trigger') 154 | removeDataSource(mockDataSource1) 155 | expect(triggerSpy).toHaveBeenCalledWith('data-source:changed') 156 | }) 157 | 158 | it('should trigger DATA_SOURCE_CHANGED event when setting data sources', () => { 159 | const triggerSpy = jest.spyOn(editor, 'trigger') 160 | setDataSources([mockDataSource1]) 161 | expect(triggerSpy).toHaveBeenCalledWith('data-source:changed') 162 | }) 163 | 164 | it('should throw error when not initialized', async () => { 165 | // Create a new registry instance to test uninitialized state 166 | jest.resetModules() 167 | const { getAllDataSources: uninitializedGetAll } = await import('./dataSourceRegistry') 168 | expect(() => uninitializedGetAll()).toThrow('DataSourceRegistry not initialized') 169 | }) 170 | }) -------------------------------------------------------------------------------- /integration-tests/graphql-modules.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "__typename": "ApplicationQueries", 4 | "queryPageContents": [ 5 | { 6 | "__typename": "Page", 7 | "flatData": { 8 | "__typename": "PageFlatDataDto", 9 | "label": "Home", 10 | "modules": [ 11 | { 12 | "__typename": "PageDataModulesChildDto", 13 | "item": { 14 | "__typename": "SimpleHeroComponent", 15 | "type": "simple-hero" 16 | } 17 | }, 18 | { 19 | "__typename": "PageDataModulesChildDto", 20 | "item": {} 21 | }, 22 | { 23 | "__typename": "PageDataModulesChildDto", 24 | "item": { 25 | "__typename": "SpecialHeadingComponent", 26 | "type": "special-heading" 27 | } 28 | }, 29 | { 30 | "__typename": "PageDataModulesChildDto", 31 | "item": {} 32 | }, 33 | { 34 | "__typename": "PageDataModulesChildDto", 35 | "item": {} 36 | }, 37 | { 38 | "__typename": "PageDataModulesChildDto", 39 | "item": {} 40 | }, 41 | { 42 | "__typename": "PageDataModulesChildDto", 43 | "item": { 44 | "__typename": "SpecialHeadingComponent", 45 | "type": "special-heading" 46 | } 47 | }, 48 | { 49 | "__typename": "PageDataModulesChildDto", 50 | "item": {} 51 | }, 52 | { 53 | "__typename": "PageDataModulesChildDto", 54 | "item": {} 55 | }, 56 | { 57 | "__typename": "PageDataModulesChildDto", 58 | "item": {} 59 | }, 60 | { 61 | "__typename": "PageDataModulesChildDto", 62 | "item": {} 63 | }, 64 | { 65 | "__typename": "PageDataModulesChildDto", 66 | "item": {} 67 | }, 68 | { 69 | "__typename": "PageDataModulesChildDto", 70 | "item": {} 71 | }, 72 | { 73 | "__typename": "PageDataModulesChildDto", 74 | "item": { 75 | "__typename": "SpecialHeadingComponent", 76 | "type": "special-heading" 77 | } 78 | }, 79 | { 80 | "__typename": "PageDataModulesChildDto", 81 | "item": {} 82 | }, 83 | { 84 | "__typename": "PageDataModulesChildDto", 85 | "item": {} 86 | }, 87 | { 88 | "__typename": "PageDataModulesChildDto", 89 | "item": { 90 | "__typename": "SectionSquaresComponent", 91 | "type": "section-squares" 92 | } 93 | }, 94 | { 95 | "__typename": "PageDataModulesChildDto", 96 | "item": { 97 | "__typename": "SectionSquaresComponent", 98 | "type": "section-squares" 99 | } 100 | }, 101 | { 102 | "__typename": "PageDataModulesChildDto", 103 | "item": { 104 | "__typename": "SpecialHeadingComponent", 105 | "type": "special-heading" 106 | } 107 | }, 108 | { 109 | "__typename": "PageDataModulesChildDto", 110 | "item": {} 111 | } 112 | ] 113 | } 114 | }, 115 | { 116 | "__typename": "Page", 117 | "flatData": { 118 | "__typename": "PageFlatDataDto", 119 | "label": "Help and support", 120 | "modules": [ 121 | { 122 | "__typename": "PageDataModulesChildDto", 123 | "item": { 124 | "__typename": "SimpleHeroComponent", 125 | "type": "simple-hero" 126 | } 127 | }, 128 | { 129 | "__typename": "PageDataModulesChildDto", 130 | "item": { 131 | "__typename": "SpecialHeadingComponent", 132 | "type": "special-heading" 133 | } 134 | }, 135 | { 136 | "__typename": "PageDataModulesChildDto", 137 | "item": { 138 | "__typename": "SpecialHeadingComponent", 139 | "type": "special-heading" 140 | } 141 | }, 142 | { 143 | "__typename": "PageDataModulesChildDto", 144 | "item": { 145 | "__typename": "SectionSquaresComponent", 146 | "type": "section-squares" 147 | } 148 | }, 149 | { 150 | "__typename": "PageDataModulesChildDto", 151 | "item": { 152 | "__typename": "SpecialHeadingComponent", 153 | "type": "special-heading" 154 | } 155 | }, 156 | { 157 | "__typename": "PageDataModulesChildDto", 158 | "item": {} 159 | }, 160 | { 161 | "__typename": "PageDataModulesChildDto", 162 | "item": { 163 | "__typename": "SpecialHeadingComponent", 164 | "type": "special-heading" 165 | } 166 | } 167 | ] 168 | } 169 | } 170 | ] 171 | }, 172 | "extensions": { 173 | "tracing": { 174 | "version": 1, 175 | "startTime": "2025-08-21T00:55:20.1733794Z", 176 | "endTime": "2025-08-21T00:55:20.1887985Z", 177 | "duration": 15419100, 178 | "parsing": { 179 | "startOffset": 500, 180 | "duration": 28100 181 | }, 182 | "validation": { 183 | "startOffset": 29300, 184 | "duration": 467900 185 | }, 186 | "execution": { 187 | "resolvers": [] 188 | } 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /example.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | __typename 3 | queryPageContents(skip: 0) { 4 | __typename 5 | data { 6 | __typename 7 | slug { 8 | __typename 9 | fr 10 | en 11 | 12 | } 13 | modules { 14 | __typename 15 | en { 16 | __typename 17 | item__Dynamic 18 | 19 | } 20 | 21 | } 22 | 23 | } 24 | flatData { 25 | __typename 26 | shareImage { 27 | __typename 28 | url 29 | 30 | } 31 | description 32 | lang 33 | slugFr 34 | slugEn 35 | slug 36 | listImage { 37 | __typename 38 | url 39 | 40 | } 41 | title 42 | pubDate 43 | type 44 | modules { 45 | __typename 46 | 47 | item { 48 | ...on HeroWordSliderComponent { 49 | __typename 50 | type 51 | before 52 | words { 53 | __typename 54 | word 55 | 56 | } 57 | after 58 | cTAUrl 59 | cTALabel 60 | 61 | } 62 | } 63 | item { 64 | ...on SimpleHeroComponent { 65 | __typename 66 | type 67 | text 68 | secondaryUrl 69 | secondaryLabel 70 | cTAUrl 71 | cTALabel 72 | 73 | } 74 | } 75 | item { 76 | ...on SectionSlideshowUpComponent { 77 | __typename 78 | type 79 | 80 | } 81 | } 82 | item { 83 | ...on SectionImageUpComponent { 84 | __typename 85 | type 86 | image { 87 | __typename 88 | url 89 | fileName 90 | 91 | } 92 | 93 | } 94 | } 95 | item { 96 | ...on SpecialHeadingComponent { 97 | __typename 98 | styleBg 99 | type 100 | title 101 | subtitle 102 | text 103 | uRLSecondary 104 | labelSecondary 105 | uRL 106 | label 107 | 108 | } 109 | } 110 | item { 111 | ...on SectionPatternGridComponent { 112 | __typename 113 | type 114 | style 115 | title 116 | subtitle 117 | text 118 | uRLSecondary 119 | labelSecondary 120 | uRL 121 | label 122 | image { 123 | __typename 124 | url 125 | slug 126 | 127 | } 128 | shadow 129 | 130 | } 131 | } 132 | item { 133 | ...on SectionCodeComponent { 134 | __typename 135 | type 136 | code 137 | 138 | } 139 | } 140 | item { 141 | ...on SectionFriendsComponent { 142 | __typename 143 | type 144 | 145 | } 146 | } 147 | item { 148 | ...on SectionSquaresComponent { 149 | __typename 150 | type 151 | styleBg 152 | mainTitle 153 | mainSubtitle 154 | mainText 155 | cards { 156 | __typename 157 | image { 158 | __typename 159 | url 160 | fileName 161 | 162 | } 163 | title 164 | text 165 | uRL 166 | label 167 | 168 | } 169 | cTAUrl 170 | cTALabel 171 | 172 | } 173 | } 174 | item { 175 | ...on SectionSquaresFeaturesComponent { 176 | __typename 177 | type 178 | featureTitle 179 | featureSubtitle 180 | featureText 181 | 182 | } 183 | } 184 | item { 185 | ...on SectionSquaresPagesComponent { 186 | __typename 187 | type 188 | filterByType 189 | 190 | } 191 | } 192 | item { 193 | ...on SectionSquaresMastodonComponent { 194 | __typename 195 | type 196 | ctasLabel 197 | 198 | } 199 | } 200 | item { 201 | ...on SpecialHeadingImgComponent { 202 | __typename 203 | type 204 | image { 205 | __typename 206 | url 207 | slug 208 | 209 | } 210 | title 211 | subtitle 212 | text 213 | uRLSecondary 214 | labelSecondary 215 | uRL 216 | label 217 | 218 | } 219 | } 220 | item { 221 | ...on SectionRectBgComponent { 222 | __typename 223 | type 224 | title 225 | subtitle 226 | cards { 227 | __typename 228 | link 229 | image { 230 | __typename 231 | url 232 | fileName 233 | 234 | } 235 | title 236 | text 237 | 238 | } 239 | 240 | } 241 | } 242 | item { 243 | ...on SectionNetworkComponent { 244 | __typename 245 | type 246 | title 247 | subtitle 248 | cards { 249 | __typename 250 | link 251 | image { 252 | __typename 253 | url 254 | fileName 255 | 256 | } 257 | title 258 | text 259 | 260 | } 261 | 262 | } 263 | } 264 | item { 265 | ...on SectionBlogDataComponent { 266 | __typename 267 | type 268 | author { 269 | __typename 270 | flatData { 271 | __typename 272 | name 273 | 274 | } 275 | 276 | } 277 | date 278 | 279 | } 280 | } 281 | } 282 | 283 | } 284 | 285 | } 286 | findGlobalSingleton { 287 | __typename 288 | flatData { 289 | __typename 290 | favicon { 291 | __typename 292 | url 293 | 294 | } 295 | url 296 | nav { 297 | __typename 298 | label 299 | url 300 | title 301 | 302 | } 303 | footerHeadingsTitle 304 | footerHeadingsSubtitle 305 | footerHeadingsText 306 | uRLSecondary 307 | labelSecondary 308 | uRL 309 | label 310 | links { 311 | __typename 312 | title 313 | content 314 | 315 | } 316 | footerLegal 317 | 318 | } 319 | 320 | } 321 | queryGlobalContents(skip: 0) { 322 | __typename 323 | flatData { 324 | __typename 325 | banner { 326 | __typename 327 | html(indentation: 4) 328 | 329 | } 330 | bannerLink 331 | ctaCardsLabel 332 | 333 | } 334 | 335 | } 336 | queryFeatureContents(skip: 0) { 337 | __typename 338 | flatData { 339 | __typename 340 | image { 341 | __typename 342 | url 343 | fileName 344 | 345 | } 346 | title 347 | text 348 | uRL 349 | label 350 | 351 | } 352 | 353 | } 354 | 355 | } 356 | -------------------------------------------------------------------------------- /src/view/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { DATA_SOURCE_CHANGED, DATA_SOURCE_DATA_LOAD_END, COMPONENT_STATE_CHANGED, DataSourceEditorOptions, DataSourceEditorViewOptions, Properties } from '../types' 19 | import { PROPERTY_STYLES } from './defaultStyles' 20 | 21 | import { PropertiesEditor } from './properties-editor' 22 | import { CustomStatesEditor } from './custom-states-editor' 23 | 24 | import settings from './settings' 25 | import { Editor } from 'grapesjs' 26 | 27 | import '@silexlabs/expression-input' 28 | import './properties-editor' 29 | import './custom-states-editor' 30 | import canvas from './canvas' 31 | import { getElementFromOption } from '../utils' 32 | 33 | export default (editor: Editor, opts: DataSourceEditorOptions) => { 34 | const options: DataSourceEditorViewOptions = { 35 | styles: PROPERTY_STYLES, 36 | defaultFixed: false, 37 | disableStates: false, 38 | disableAttributes: false, 39 | disableProperties: false, 40 | previewDebounceDelay: 100, 41 | previewRefreshEvents: ` 42 | ${DATA_SOURCE_CHANGED} 43 | ${DATA_SOURCE_DATA_LOAD_END} 44 | ${COMPONENT_STATE_CHANGED} 45 | storage:after:load 46 | component:update:classes 47 | component:add 48 | component:remove 49 | `, 50 | ...opts.view, 51 | } 52 | 53 | if (opts.view.el) { 54 | // create a wrapper for our UI 55 | const wrapper = document.createElement('section') 56 | wrapper.classList.add('gjs-one-bg', 'ds-wrapper') 57 | 58 | // Add the web components 59 | const states = options.disableStates ? '' : ` 60 | 75 | 78 | 79 | ` 80 | const attributes = options.disableAttributes ? '' : ` 81 | 96 | 99 | 100 | ` 101 | const properties = options.disableProperties ? '' : ` 102 | 106 | 109 | 110 | ` 111 | wrapper.innerHTML = ` 112 | ${states} 113 | ${attributes} 114 | ${properties} 115 | ` 116 | 117 | // Build the settings view 118 | settings(editor, options) 119 | 120 | // The options el and button can be functions which use editor so they need to be called asynchronously 121 | editor.onReady(() => { 122 | // Get the container element for the UI 123 | const el = getElementFromOption(options.el, 'options.el') 124 | 125 | // Append the wrapper to the container 126 | el.appendChild(wrapper) 127 | 128 | // Get references to the web components 129 | const propertiesUi = wrapper.querySelector('properties-editor.ds-properties') as PropertiesEditor 130 | const statesUi = wrapper.querySelector('custom-states-editor.ds-states') as CustomStatesEditor 131 | const attributesUi = wrapper.querySelector('custom-states-editor.ds-attributes') as CustomStatesEditor 132 | 133 | // Init web components 134 | propertiesUi?.setEditor(editor) 135 | statesUi?.setEditor(editor) 136 | attributesUi?.setEditor(editor) 137 | 138 | // Show the UI when the button is clicked 139 | if (options.button) { 140 | const button = typeof options.button === 'function' ? options.button() : options.button 141 | if (!button) throw new Error(`Element ${options.button} not found`) 142 | button.on('change', () => { 143 | if (button.active) { 144 | // Move at the bottom 145 | el.appendChild(wrapper) 146 | // Show the UI 147 | wrapper.style.display = 'block' 148 | // Change web components state 149 | propertiesUi?.removeAttribute('disabled') 150 | statesUi?.removeAttribute('disabled') 151 | attributesUi?.removeAttribute('disabled') 152 | } else { 153 | // Hide the UI 154 | wrapper.style.display = 'none' 155 | // Change web components state 156 | propertiesUi?.setAttribute('disabled', '') 157 | statesUi?.setAttribute('disabled', '') 158 | attributesUi?.setAttribute('disabled', '') 159 | } 160 | }) 161 | wrapper.style.display = button.active ? 'block' : 'none' 162 | } 163 | }) 164 | } else { 165 | console.info('Dynamic data UI not enabled, please set the el option to enable it') 166 | } 167 | canvas(editor, options) 168 | } 169 | -------------------------------------------------------------------------------- /src/model/previewDataLoader.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { DataSourceId, DATA_SOURCE_DATA_LOAD_START, DATA_SOURCE_DATA_LOAD_END, DATA_SOURCE_DATA_LOAD_CANCEL } from '../types' 19 | import { getPreviewData as getPreviewDataFromManager, setPreviewData as setPreviewDataInManager } from './dataSourceManager' 20 | import { getAllDataSources } from './dataSourceRegistry' 21 | import { getPageQuery } from './queryBuilder' 22 | import { Page, Editor } from 'grapesjs' 23 | import { NOTIFICATION_GROUP } from '../utils' 24 | 25 | /** 26 | * Preview data loader state 27 | */ 28 | interface PreviewDataLoaderState { 29 | editor: Editor 30 | currentUpdatePid: number 31 | lastQueries: Record 32 | } 33 | 34 | // Global loader instance 35 | let globalLoader: PreviewDataLoaderState | null = null 36 | 37 | /** 38 | * Initialize the preview data loader 39 | */ 40 | export function initializePreviewDataLoader(editor: Editor): void { 41 | globalLoader = { 42 | editor, 43 | currentUpdatePid: 0, 44 | lastQueries: {}, 45 | } 46 | } 47 | 48 | /** 49 | * Get the global loader (throws if not initialized) 50 | */ 51 | function getLoader(): PreviewDataLoaderState { 52 | if (!globalLoader) { 53 | throw new Error('PreviewDataLoader not initialized. Call initializePreviewDataLoader first.') 54 | } 55 | return globalLoader 56 | } 57 | 58 | /** 59 | * Compare two query objects to see if they are equal 60 | */ 61 | function areQueriesEqual(queries1: Record, queries2: Record): boolean { 62 | const keys1 = Object.keys(queries1).sort() 63 | const keys2 = Object.keys(queries2).sort() 64 | 65 | // Check if they have the same number of keys 66 | if (keys1.length !== keys2.length) { 67 | return false 68 | } 69 | 70 | // Check if all keys are the same 71 | if (!keys1.every(key => keys2.includes(key))) { 72 | return false 73 | } 74 | 75 | // Check if all values are the same 76 | return keys1.every(key => queries1[key] === queries2[key]) 77 | } 78 | 79 | /** 80 | * Load preview data for the current page 81 | * @param forceRefresh - If true, bypass query comparison and force refresh 82 | */ 83 | export async function loadPreviewData(forceRefresh: boolean = false): Promise { 84 | const loader = getLoader() 85 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_START) 86 | 87 | const page = loader.editor.Pages.getSelected() 88 | if (!page) return 89 | 90 | // Get current queries 91 | const currentQueries = getPageQuery(page, loader.editor) 92 | 93 | // Compare with last queries to see if we need to refresh 94 | const queriesChanged = !areQueriesEqual(loader.lastQueries, currentQueries) 95 | 96 | if (!forceRefresh && !queriesChanged) { 97 | // Queries haven't changed, no need to refresh data sources 98 | // But still trigger load end to maintain expected event flow 99 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_END, getPreviewDataFromManager()) 100 | return 101 | } 102 | 103 | // Update last queries 104 | loader.lastQueries = { ...currentQueries } 105 | 106 | loader.currentUpdatePid++ 107 | const data = await fetchPagePreviewData(page) 108 | 109 | if (data !== 'interrupted') { 110 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_END, data) 111 | } else { 112 | console.warn(`Preview data update process for PID ${loader.currentUpdatePid} was interrupted.`) 113 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_CANCEL, data) 114 | } 115 | } 116 | 117 | /** 118 | * Fetch preview data for a specific page 119 | * @param page - The page object for which preview data needs to be fetched 120 | * @return The preview data returned by all data sources, or 'interrupted' if cancelled 121 | */ 122 | export async function fetchPagePreviewData(page: Page): Promise | 'interrupted'> { 123 | const loader = getLoader() 124 | const myPid = loader.currentUpdatePid 125 | const queries = getPageQuery(page, loader.editor) 126 | 127 | // Reset preview data 128 | setPreviewDataInManager({}) 129 | 130 | try { 131 | const results = await Promise.all( 132 | Object.entries(queries) 133 | .map(async ([dataSourceId, query]) => { 134 | if (myPid !== loader.currentUpdatePid) return 135 | 136 | const ds = getAllDataSources().find(ds => ds.id === dataSourceId) 137 | if (!ds) { 138 | console.error(`Data source ${dataSourceId} not found`) 139 | return null 140 | } 141 | 142 | if (!ds.isConnected()) { 143 | console.warn(`Data source ${dataSourceId} is not connected.`) 144 | return null 145 | } 146 | 147 | try { 148 | const value = await ds.fetchValues(query) 149 | const currentData = getPreviewDataFromManager() 150 | setPreviewDataInManager({ ...currentData, [dataSourceId]: value }) 151 | return { dataSourceId, value } 152 | } catch (err) { 153 | console.error(`Error fetching preview data for data source ${dataSourceId}:`, err) 154 | loader.editor.runCommand('notifications:add', { 155 | type: 'error', 156 | group: NOTIFICATION_GROUP, 157 | message: `Error fetching preview data for data source ${dataSourceId}: ${err}`, 158 | }) 159 | return null 160 | } 161 | }) 162 | ) 163 | 164 | if (myPid !== loader.currentUpdatePid) return 'interrupted' 165 | 166 | return results 167 | .filter(result => result !== null) 168 | .reduce((acc, result) => { 169 | const { dataSourceId, value } = result! 170 | acc[dataSourceId] = value 171 | return acc 172 | }, {} as Record) 173 | } catch (err) { 174 | console.error('Error while fetching preview data:', err) 175 | loader.editor.runCommand('notifications:add', { 176 | type: 'error', 177 | group: NOTIFICATION_GROUP, 178 | message: `Error while fetching preview data: ${err}`, 179 | }) 180 | return {} 181 | } 182 | } 183 | 184 | /** 185 | * Get current preview data 186 | */ 187 | export function getPreviewData(): Record { 188 | return getPreviewDataFromManager() 189 | } 190 | 191 | /** 192 | * Clear preview data 193 | */ 194 | export function clearPreviewData(): void { 195 | setPreviewDataInManager({}) 196 | } 197 | -------------------------------------------------------------------------------- /src/model/state.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { Component, Editor } from 'grapesjs' 19 | import { Expression, StateId, State } from '../types' 20 | 21 | /** 22 | * @fileoverview This file contains the model for components states 23 | * A state is a value which can be used in expressions 24 | * If exported it will be available in the context of child components 25 | */ 26 | 27 | // Keys to store the states in the component 28 | const EXPORTED_STATES_KEY = 'publicStates' 29 | const PRIVATE_STATES_KEY = 'privateStates' 30 | 31 | /** 32 | * Persistant ID is used to identify a component reliably 33 | * It will be stored with the website data 34 | */ 35 | const PERSISTANT_ID_KEY = 'id-plugin-data-source' 36 | 37 | /** 38 | * Override the prefix of state names 39 | */ 40 | export const COMPONENT_NAME_PREFIX = 'nameForDataSource' 41 | 42 | /** 43 | * Types 44 | */ 45 | export interface StoredState { 46 | label?: string 47 | hidden?: boolean 48 | expression: Expression 49 | } 50 | 51 | export interface StoredStateWithId extends StoredState { 52 | id: StateId 53 | } 54 | 55 | export type PersistantId = string 56 | 57 | /** 58 | * Get the persistant ID of a component 59 | */ 60 | export function getPersistantId(component: Component): PersistantId | null { 61 | return component.get(PERSISTANT_ID_KEY) ?? null 62 | } 63 | 64 | /** 65 | * Get the persistant ID of a component and create it if it does not exist 66 | */ 67 | export function getOrCreatePersistantId(component: Component): PersistantId { 68 | const persistantId = component.get(PERSISTANT_ID_KEY) 69 | if(persistantId) return persistantId 70 | const newPersistantId = `${component.ccid}-${Math.round(Math.random() * 10000)}` as PersistantId 71 | component.set(PERSISTANT_ID_KEY, newPersistantId) 72 | return newPersistantId 73 | } 74 | 75 | /** 76 | * Find a component by its persistant ID in the current page 77 | */ 78 | export function getComponentByPersistentId(id: PersistantId, editor: Editor): Component | null { 79 | const pages = editor.Pages.getAll() 80 | for(const page of pages) { 81 | const body = page.getMainComponent() 82 | const component = getChildByPersistantId(id, body) 83 | if(component) return component 84 | } 85 | return null 86 | } 87 | 88 | /** 89 | * Find a component by its persistant ID in 90 | */ 91 | export function getChildByPersistantId(id: PersistantId, parent: Component): Component | null { 92 | if(getPersistantId(parent) === id) return parent 93 | for(const child of parent.components()) { 94 | const component = getChildByPersistantId(id, child) 95 | if(component) return component 96 | } 97 | return null 98 | } 99 | 100 | /** 101 | * Find a component by its persistant ID in the current page 102 | */ 103 | export function getParentByPersistentId(id: PersistantId, component: Component | undefined): Component | null { 104 | if(!component) return null 105 | if(getPersistantId(component) === id) return component 106 | return getParentByPersistentId(id, component.parent()) 107 | } 108 | 109 | /** 110 | * Get the display name of a state 111 | */ 112 | export function getStateDisplayName(child: Component, state: State): string { 113 | const component = getParentByPersistentId(state.componentId, child) 114 | //const name = component?.getName() ?? '[Not found]' 115 | const prefix = component?.get(COMPONENT_NAME_PREFIX) ?? '' // `${name}'s` 116 | return `${prefix ? prefix + ' ' : ''}${state.label || state.storedStateId}` 117 | } 118 | 119 | /** 120 | * Callbacks called when a state is changed 121 | * @returns A function to remove the callback 122 | */ 123 | const _callbacks: ((state: StoredState | null, component: Component) => void)[] = [] 124 | export function onStateChange(callback: (state: StoredState | null, component: Component) => void): () => void { 125 | _callbacks.push(callback) 126 | return () => { 127 | const index = _callbacks.indexOf(callback) 128 | if(index >= 0) _callbacks.splice(index, 1) 129 | } 130 | } 131 | function fireChange(state: StoredState | null, component: Component) { 132 | _callbacks.forEach(callback => callback(state, component)) 133 | } 134 | 135 | /** 136 | * List all exported states 137 | */ 138 | export function getStateIds(component: Component, exported: boolean = true, before?: StateId): StateId[] { 139 | try { 140 | const states = component.get(exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY) as StoredStateWithId[] ?? [] 141 | const allStates = states 142 | .sort(a => a.hidden ? -1 : 0) // Hidden states first 143 | .map(state => state.id) 144 | if(before) { 145 | const index = allStates.indexOf(before) 146 | if(index < 0) return allStates 147 | return allStates.slice(0, index) 148 | } 149 | return allStates 150 | } catch(e) { 151 | // this happens when the old deprecated state system is used 152 | console.error('Error while getting state ids', e) 153 | return [] 154 | } 155 | } 156 | 157 | /** 158 | * List all exported states 159 | */ 160 | export function getStates(component: Component, exported: boolean = true): StoredState[] { 161 | const states = component.get(exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY) as StoredStateWithId[] ?? [] 162 | return states.map(state => ({ 163 | label: state.label, 164 | hidden: state.hidden, 165 | expression: state.expression, 166 | })) 167 | } 168 | 169 | /** 170 | * Get the name of a state variable 171 | * Useful to generate code 172 | */ 173 | export function getStateVariableName(componentId: string, stateId: StateId): string { 174 | return `state_${ componentId }_${ stateId }` 175 | } 176 | 177 | /** 178 | * Get a state 179 | */ 180 | export function getState(component: Component, id: StateId, exported: boolean = true): StoredState | null { 181 | const states = component.get(exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY) as StoredStateWithId[] ?? [] 182 | const state = states.find(state => state.id === id) ?? null 183 | if(!state) { 184 | return null 185 | } 186 | return { 187 | label: state.label, 188 | hidden: state.hidden, 189 | expression: state.expression, 190 | } 191 | } 192 | 193 | /** 194 | * Set a state 195 | * The state will be updated or created at the end of the list 196 | * Note: index is not used in this project anymore (maybe in apps using this plugins) 197 | */ 198 | export function setState(component: Component, id: StateId, state: StoredState, exported = true, index = -1): void { 199 | const key = exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY 200 | const states = component.get(key) as StoredStateWithId[] ?? [] 201 | const existing = states.find(s => s.id === id) ?? null 202 | if(existing) { 203 | component.set(key, states.map(s => s.id !== id ? s : { 204 | id, 205 | ...state, 206 | })) 207 | } else { 208 | component.set(key, [ 209 | ...states, 210 | { 211 | id, 212 | ...state, 213 | }, 214 | ]) 215 | } 216 | // Set the index if needed 217 | if(index >= 0) { 218 | const states = [...component.get(key) as StoredStateWithId[]] 219 | const state = states.find(s => s.id === id) 220 | if(state && index < states.length) { 221 | states.splice(states.indexOf(state), 1) 222 | states.splice(index, 0, state) 223 | component.set(key, states) 224 | } 225 | } 226 | // Notify the change 227 | fireChange({ 228 | label: state.label, 229 | hidden: state.hidden, 230 | expression: state.expression, 231 | }, component) 232 | } 233 | 234 | /** 235 | * Remove a state 236 | */ 237 | export function removeState(component: Component, id: StateId, exported: boolean = true): void { 238 | const key = exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY 239 | const states = component.get(key) as StoredStateWithId[] ?? [] 240 | const newStates = states.filter(s => s.id !== id) 241 | component.set(key, newStates) 242 | fireChange(null, component) 243 | } 244 | -------------------------------------------------------------------------------- /src/model/expressionEvaluator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { Component } from 'grapesjs' 19 | import { Expression, StoredToken, State, Property, Filter, DataSourceId, IDataSource, FIXED_TOKEN_ID } from '../types' 20 | import { DataSourceManagerState } from './dataSourceManager' 21 | import { getParentByPersistentId, getState } from './state' 22 | import { fromStored } from './token' 23 | import { toExpression } from '../utils' 24 | 25 | /** 26 | * Expression evaluation context 27 | */ 28 | export interface EvaluationContext { 29 | readonly dataSources: readonly IDataSource[] 30 | readonly filters: readonly Filter[] 31 | readonly previewData: Record 32 | readonly component: Component 33 | readonly resolvePreviewIndex: boolean 34 | } 35 | 36 | /** 37 | * Handle preview index for array data 38 | */ 39 | export function handlePreviewIndex(value: unknown, token: StoredToken): unknown { 40 | if (typeof token.previewIndex === 'undefined') { 41 | return value 42 | } 43 | 44 | if (Array.isArray(value)) { 45 | return value[token.previewIndex] 46 | } 47 | return value 48 | } 49 | 50 | /** 51 | * Evaluate a property token 52 | */ 53 | export function evaluatePropertyToken( 54 | token: Property, 55 | remaining: Expression, 56 | context: EvaluationContext, 57 | prevValues: unknown 58 | ): unknown { 59 | // Handle "fixed" property (hard coded string set by the user) 60 | if (token.fieldId === FIXED_TOKEN_ID) { 61 | return evaluateExpressionTokens(remaining, context, token.options?.value) 62 | } 63 | 64 | // Get data object 65 | let prevObj 66 | if (typeof prevValues === 'undefined' || prevValues === null) { 67 | if (!token.dataSourceId) { 68 | throw new Error(`Data source ID is missing for token: ${JSON.stringify(token)}`) 69 | } 70 | prevObj = context.previewData[token.dataSourceId] 71 | } else { 72 | prevObj = prevValues 73 | } 74 | 75 | // Get the next value 76 | let value = prevObj ? (prevObj as Record)[token.fieldId] : null 77 | 78 | // Handle preview index if resolvePreviewIndex is true 79 | // Don't resolve if next token is a filter - filters need to operate on full arrays 80 | if (context.resolvePreviewIndex) { 81 | const nextToken = remaining[0] 82 | if (!nextToken || nextToken.type !== 'filter') { 83 | value = handlePreviewIndex(value, token) 84 | } 85 | } 86 | 87 | // For non-final tokens, handle preview index only if next token is not a filter 88 | // Filters need to operate on full arrays, not individual items 89 | if (remaining.length > 0 && !context.resolvePreviewIndex) { 90 | const nextToken = remaining[0] 91 | if (nextToken.type !== 'filter') { 92 | value = handlePreviewIndex(value, token) 93 | } 94 | } 95 | 96 | // Special handling for items state 97 | // @ts-expect-error - Runtime property check for items handling 98 | if (token.isItems && typeof token.previewIndex !== 'undefined') { 99 | if (remaining.length > 0) { 100 | value = [value] 101 | } 102 | } 103 | 104 | return evaluateExpressionTokens(remaining, context, value) 105 | } 106 | 107 | /** 108 | * Evaluate expression tokens recursively 109 | */ 110 | export function evaluateExpressionTokens( 111 | expression: Expression, 112 | context: EvaluationContext, 113 | prevValues: unknown = null, 114 | ): unknown { 115 | if (expression.length === 0) { 116 | return prevValues 117 | } 118 | 119 | // Always create defensive copies of tokens to prevent mutations from affecting original data 120 | const cleanExpression = expression.map(token => ({ ...token })) 121 | const [token, ...rest] = cleanExpression 122 | 123 | switch (token.type) { 124 | case 'state': { 125 | return evaluateStateToken(token as State, rest, context, prevValues) 126 | } 127 | case 'property': { 128 | return evaluatePropertyToken(token as Property, rest, context, prevValues) 129 | } 130 | case 'filter': { 131 | return evaluateFilterToken(token as Filter, rest, context, prevValues) 132 | } 133 | default: 134 | throw new Error(`Unsupported token type: ${JSON.stringify(token)}`) 135 | } 136 | } 137 | 138 | /** 139 | * Evaluate a state token 140 | */ 141 | export function evaluateStateToken( 142 | state: State, 143 | remaining: Expression, 144 | context: EvaluationContext, 145 | prevValues: unknown 146 | ): unknown { 147 | const resolvedExpression = resolveStateExpression(state, context.component, context) 148 | if (!resolvedExpression) { 149 | throw new Error(`Unable to resolve state: ${JSON.stringify(state)}`) 150 | } 151 | 152 | // Special handling for items state - always wrap result in array when resolvePreviewIndex is true 153 | const previewIndex = resolvedExpression[resolvedExpression.length - 1]?.previewIndex 154 | if (state.storedStateId === 'items' && typeof previewIndex !== 'undefined') { 155 | // @ts-expect-error - Adding runtime property for items state handling 156 | resolvedExpression[0].isItems = true 157 | } 158 | 159 | return evaluateExpressionTokens([...resolvedExpression, ...remaining], context, prevValues) 160 | } 161 | 162 | /** 163 | * Resolve a state token to its expression 164 | */ 165 | export function resolveStateExpression( 166 | state: State, 167 | component: Component, 168 | context: DataSourceManagerState | EvaluationContext | readonly IDataSource[] 169 | ): Expression | null { 170 | const parent = getParentByPersistentId(state.componentId, component) 171 | if (!parent) { 172 | console.error('Component not found for state', state, component.get('id-plugin-data-source')) 173 | return null 174 | } 175 | 176 | // Get the expression of the state 177 | const storedState = getState(parent, state.storedStateId, state.exposed) 178 | if (!storedState?.expression) { 179 | console.warn('State is not defined on component', parent.getId(), state, storedState) 180 | return null 181 | } 182 | 183 | // Create a minimal DataTree-like object for fromStored compatibility 184 | return storedState.expression 185 | .flatMap((token: StoredToken) => { 186 | switch (token.type) { 187 | case 'state': { 188 | return resolveStateExpression(fromStored(token, component.getId()), parent, context) ?? [] 189 | } 190 | default: 191 | return token 192 | } 193 | }) 194 | } 195 | 196 | /** 197 | * Evaluate a filter token (Liquid filter) 198 | */ 199 | export function evaluateFilterToken( 200 | token: Filter, 201 | remaining: Expression, 202 | context: EvaluationContext, 203 | prevValues: unknown 204 | ): unknown { 205 | const options = Object.entries(token.options).reduce((acc, [key, value]) => { 206 | // If value is a primitive (number, string, boolean), use it directly 207 | // Only evaluate as expression if it's an actual expression object/array 208 | const expression = toExpression(value) 209 | acc[key] = expression ? evaluateExpressionTokens(expression, context, null) : value 210 | return acc 211 | }, {} as Record) 212 | 213 | const filter = context.filters.find(f => f.id === token.id) 214 | if (!filter) { 215 | throw new Error(`Filter not found: ${token.id}`) 216 | } 217 | 218 | let value 219 | try { 220 | value = filter.apply(prevValues, options) 221 | } catch (e) { 222 | console.warn(`Filter "${filter.id}" error:`, e, { 223 | filter: filter.id, 224 | prevValues, 225 | options, 226 | valueType: typeof prevValues, 227 | isArray: Array.isArray(prevValues), 228 | isNull: prevValues === null, 229 | }) 230 | // Mimic behavior of liquid - return null on error 231 | return null 232 | } 233 | 234 | // Don't resolve before filters - they need arrays 235 | const nextIsFilter = remaining.length > 0 && remaining[0]?.type === 'filter' 236 | if (!nextIsFilter && (context.resolvePreviewIndex || remaining.length > 0)) { 237 | value = handlePreviewIndex(value, token) 238 | } 239 | 240 | return evaluateExpressionTokens(remaining, context, value) 241 | } 242 | -------------------------------------------------------------------------------- /src/model/completion.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'grapesjs' 2 | import { Context, DataSourceId, Expression, Field, Filter, IDataSource, Property, State, StateId, Token, Type, TypeId } from '../types' 3 | import { DataSourceManagerState, getManager, getTypes, STANDARD_TYPES } from './dataSourceManager' 4 | import { getOrCreatePersistantId, getState, getStateIds } from './state' 5 | import { getExpressionResultType, getTokenOptions } from './token' 6 | import { getFixedToken, NOTIFICATION_GROUP } from '../utils' 7 | 8 | /** 9 | * Get the context of a component 10 | * This includes all parents states, data sources queryable values, values provided in the options 11 | */ 12 | export function getContext(component: Component, manager: DataSourceManagerState, currentStateId?: StateId, hideLoopData = false): Context { 13 | if (!component) { 14 | console.error('Component is required for context') 15 | throw new Error('Component is required for context') 16 | } 17 | // Get all queryable values from all data sources 18 | const queryable: Property[] = manager.cachedQueryables 19 | .map((field: Field) => { 20 | if (!field.dataSourceId) throw new Error(`Type ${field.id} has no data source`) 21 | return fieldToToken(field) 22 | }) 23 | // Get all states in the component scope 24 | const states: State[] = [] 25 | const loopProperties: Token[] = [] 26 | let parent = component 27 | while (parent) { 28 | // Get explicitely set states 29 | states 30 | .push(...(getStateIds(parent, true, parent === component ? currentStateId : undefined) 31 | .map((stateId: StateId): State => ({ 32 | type: 'state', 33 | storedStateId: stateId, 34 | previewIndex: 8888, 35 | label: getState(parent, stateId, true)?.label || stateId, 36 | componentId: getOrCreatePersistantId(parent), 37 | exposed: true, 38 | })))) 39 | // Get states from loops 40 | if (parent !== component || !hideLoopData) { // If it is a loop on the parent or if we don't hide the loop data 41 | const loopDataState = getState(parent, '__data', false) 42 | if (loopDataState) { 43 | try { 44 | const loopDataField = getExpressionResultType(loopDataState.expression, parent) 45 | if (loopDataField) { 46 | const displayName = (label: string) => `${parent.getName() ?? 'Unknown'}'s ${loopDataField.label} ${label}` 47 | if (loopDataField.kind === 'list') { 48 | loopProperties.push({ 49 | type: 'state', 50 | storedStateId: '__data', 51 | componentId: getOrCreatePersistantId(parent), 52 | previewIndex: loopDataField.previewIndex, 53 | exposed: false, 54 | forceKind: 'object', // FIXME: this may be a scalar 55 | label: `Loop item (${loopDataField.label})`, 56 | }, { 57 | type: 'property', 58 | propType: 'field', 59 | fieldId: 'forloop.index0', 60 | label: displayName('forloop.index0'), 61 | kind: 'scalar', 62 | typeIds: ['number'], 63 | }, { 64 | type: 'property', 65 | propType: 'field', 66 | fieldId: 'forloop.index', 67 | label: displayName('forloop.index'), 68 | kind: 'scalar', 69 | typeIds: ['number'], 70 | }) 71 | } else { 72 | console.warn('Loop item is not a list for component', parent, 'and state', loopDataState) 73 | } 74 | } else { 75 | console.warn('Loop item type not found for component', parent, 'and state', loopDataState) 76 | } 77 | } catch { 78 | console.error('Error while getting loop item for component', parent, 'and state', loopDataState) 79 | } 80 | } 81 | } 82 | // Go up to parent 83 | parent = parent.parent() as Component 84 | } 85 | // Get filters which accept no input 86 | const filters: Filter[] = manager.filters 87 | .filter(filter => { 88 | try { 89 | return filter.validate(null) 90 | } catch (e) { 91 | console.warn('Filter validate error:', e, {filter}) 92 | return false 93 | } 94 | }) 95 | // Add a fixed value 96 | const fixedValue = getFixedToken('') 97 | // Return the context 98 | return [ 99 | ...queryable, 100 | ...states, 101 | ...loopProperties, 102 | ...filters, 103 | fixedValue, 104 | ] 105 | } 106 | 107 | /** 108 | * Create a property token from a field 109 | */ 110 | export function fieldToToken(field: Field): Property { 111 | if (!field) throw new Error('Field is required for token') 112 | if (!field.dataSourceId) throw new Error(`Field ${field.id} has no data source`) 113 | return { 114 | type: 'property', 115 | propType: 'field', 116 | fieldId: field.id, 117 | label: field.label, 118 | typeIds: field.typeIds, 119 | dataSourceId: field.dataSourceId, 120 | kind: field.kind, 121 | ...getTokenOptions(field) ?? {}, 122 | } 123 | } 124 | 125 | function getType(typeId: TypeId, dataSourceId: DataSourceId | null, componentId: string | null): Type { 126 | const manager = getManager() 127 | if(dataSourceId) { 128 | // Get the data source 129 | const dataSource = manager.dataSources 130 | .find((dataSource: IDataSource) => !dataSourceId || dataSource.id === dataSourceId) 131 | if(!dataSource) throw new Error(`Data source not found ${dataSourceId}`) 132 | // Get its types 133 | const types = dataSource?.getTypes() 134 | // Return the requested type 135 | const type = types.find((type: Type) => type.id === typeId) 136 | if (!type) { 137 | manager.editor.runCommand('notifications:add', { 138 | type: 'error', 139 | group: NOTIFICATION_GROUP, 140 | message: `Type not found ${dataSourceId ?? ''}.${typeId}`, 141 | componentId, 142 | }) 143 | throw new Error(`Type not found ${dataSourceId ?? ''}.${typeId}`) 144 | } 145 | return type 146 | } else { 147 | // No data source id: search in standard types 148 | const standardType = STANDARD_TYPES.find(type => type.id === typeId.toLowerCase()) 149 | if(standardType) return standardType 150 | // No data source id: search in all types 151 | const type = getTypes().find(type => type.id === typeId) 152 | if (!type) throw new Error(`Unknown type ${typeId}`) 153 | return type 154 | } 155 | } 156 | 157 | /** 158 | * Auto complete an expression 159 | * @returns a list of possible tokens to add to the expression 160 | */ 161 | export function getCompletion(options: { component: Component, expression: Expression, manager: DataSourceManagerState, rootType?: TypeId, currentStateId?: StateId, hideLoopData?: boolean}): Context { 162 | const { component, expression, manager, rootType, currentStateId, hideLoopData } = options 163 | if (!component) throw new Error('Component is required for completion') 164 | if (!expression) throw new Error('Expression is required for completion') 165 | if (expression.length === 0) { 166 | if (rootType) { 167 | const type = getType(rootType, null, component.getId()) 168 | if (!type) { 169 | console.warn('Root type not found', rootType) 170 | return [] 171 | } 172 | return type.fields 173 | .map((field: Field) => fieldToToken(field)) 174 | } 175 | return getContext(component, manager, currentStateId, hideLoopData) 176 | } 177 | const field = getExpressionResultType(expression, component) 178 | if (!field) { 179 | console.warn('Result type not found for expression', expression) 180 | return [] 181 | } 182 | return ([] as Token[]) 183 | // Add fields if the kind is object 184 | .concat(field.kind === 'object' ? field.typeIds 185 | // Find possible types 186 | .map((typeId: TypeId) => getType(typeId, field.dataSourceId ?? null, component.getId())) 187 | // Add all of their fields 188 | .flatMap((type: Type | null) => type?.fields ?? []) 189 | // To token 190 | .flatMap( 191 | (fieldOfField: Field): Token[] => { 192 | // const t: Type | null = this.findType(field.typeIds, field.dataSourceId) 193 | // if(!t) throw new Error(`Type ${field.typeIds} not found`) 194 | return fieldOfField.typeIds.map((typeId: TypeId) => ({ 195 | ...fieldToToken(fieldOfField), 196 | typeIds: [typeId ], 197 | })) 198 | } 199 | ) : []) 200 | // Add filters 201 | .concat( 202 | manager.filters 203 | // Match input type 204 | .filter((filter: Filter) => { 205 | try { 206 | return filter.validate(field) 207 | } catch (e) { 208 | console.warn('Filter validate error:', e, {filter, field }) 209 | return false 210 | } 211 | }) 212 | ) 213 | } 214 | -------------------------------------------------------------------------------- /src/view/properties-editor.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import {LitElement, html} from 'lit' 19 | import { ref } from 'lit/directives/ref.js' 20 | import {property} from 'lit/decorators.js' 21 | 22 | import './state-editor' 23 | import { StateEditor } from './state-editor' 24 | import { Component, Editor } from 'grapesjs' 25 | import { PROPERTY_STYLES } from './defaultStyles' 26 | import { fromStored } from '../model/token' 27 | import { BinaryOperator, Properties, Token, UnariOperator } from '../types' 28 | import { getState, setState } from '../model/state' 29 | import { getFixedToken } from '../utils' 30 | 31 | /** 32 | * Editor for selected element's properties 33 | * 34 | * Usage: 35 | * 36 | * ``` 37 | * 38 | * 39 | * 40 | * ``` 41 | * 42 | */ 43 | 44 | export class PropertiesEditor extends LitElement { 45 | @property({type: Boolean}) 46 | disabled = false 47 | 48 | @property({type: Boolean, attribute: 'default-fixed'}) 49 | defaultFixed = false 50 | 51 | inputs: Record = { 52 | innerHTML: undefined, 53 | condition: undefined, 54 | condition2: undefined, 55 | __data: undefined, 56 | } 57 | 58 | private editor: Editor | null = null 59 | private redrawing = false 60 | 61 | setEditor(editor: Editor) { 62 | if (this.editor) { 63 | console.warn('property-editor setEditor already set') 64 | return 65 | } 66 | this.editor = editor 67 | 68 | // Update the UI when a page is added/renamed/removed 69 | this.editor.on('page', () => this.requestUpdate()) 70 | 71 | // Update the UI on component selection change 72 | this.editor.on('component:selected', () => this.requestUpdate()) 73 | 74 | // Update the UI on component change 75 | this.editor.on('component:update', () => this.requestUpdate()) 76 | } 77 | 78 | override render() { 79 | super.render() 80 | this.redrawing = true 81 | const selected = this.editor?.getSelected() 82 | const head = html` 83 | 86 | 87 | ` 88 | const empty = html` 89 | ${head} 90 |

Select an element to edit its properties

91 | ` 92 | if(!this.editor || this.disabled) { 93 | this.resetInputs() 94 | this.redrawing = false 95 | return html`` 96 | } 97 | if(!selected || selected.get('tagName') === 'body') { 98 | this.resetInputs() 99 | this.redrawing = false 100 | return empty 101 | } 102 | const result = html` 103 | ${head} 104 |
105 |
106 |
107 | Properties 108 |
109 | Help 110 |
111 | Elements properties are expressions that can replace the HTML attributes of the element or it's whole content (innerHTML). 112 | Learn more about element properties 113 |
114 |
115 |
116 |
117 |
118 | ${[ 119 | {label: 'HTML content', name: Properties.innerHTML, publicState: false}, 120 | ].map(({label, name, publicState}) => this.renderStateEditor(selected, label, name, publicState))} 121 |
122 |
123 |
124 |
125 | ${this.renderStateEditor(selected, 'Visibility Condition', Properties.condition, false)} 126 |
127 | ... is 128 | 146 | ${ this.renderStateEditor(selected, '', Properties.condition2, false, false, selected.has('conditionOperator') && Object.values(BinaryOperator).includes(selected.get('conditionOperator'))) } 147 |
148 |
149 |
150 |
151 | ${this.renderStateEditor(selected, 'Loop Data', Properties.__data, false, true)} 152 |
153 |
154 | ` 155 | this.redrawing = false 156 | return result 157 | } 158 | 159 | resetInputs() { 160 | this.inputs = { 161 | innerHTML: undefined, 162 | condition: undefined, 163 | condition2: undefined, 164 | __data: undefined, 165 | } 166 | } 167 | 168 | renderStateEditor(selected: Component, label: string, name: Properties, publicState: boolean, hideLoopData = false, visible = true) { 169 | return html` 170 | { 179 | // Get the stateEditor ref 180 | if (el) { 181 | // Set the editor - we could do this only once 182 | const stateEditor = el as StateEditor 183 | // Store the stateEditor ref and the component it is representing 184 | if (!this.inputs[name]) { 185 | this.inputs[name] = { 186 | stateEditor, 187 | selected: undefined, // clear the selected component so that we update the data 188 | } 189 | } 190 | } 191 | // Finally update the data 192 | if (this.inputs[name]) { 193 | const stateEditorFinally = this.inputs[name]!.stateEditor 194 | this.redrawing = true 195 | try { 196 | stateEditorFinally.data = this.getTokens(selected, name, publicState) 197 | } catch (e) { 198 | console.error('Error setting data', e) 199 | stateEditorFinally.data = [getFixedToken(`Error setting data: ${e}`)] 200 | } 201 | this.redrawing = false 202 | // Store the selected component 203 | this.inputs[name]!.selected = selected 204 | } 205 | })} 206 | @change=${() => this.onChange(selected, name, publicState)} 207 | ?disabled=${this.disabled} 208 | > 209 | 210 | 211 | ` 212 | } 213 | 214 | onChange(component: Component, name: Properties, publicState: boolean) { 215 | const {stateEditor} = this.inputs[name]! 216 | if(this.redrawing) return 217 | if (name === Properties.__data) { 218 | // Handle the case when data is empty (after clearing) 219 | if (stateEditor.data.length === 0) { 220 | setState(component, name, { 221 | expression: [], 222 | }, publicState) 223 | } else { 224 | setState(component, name, { 225 | expression: stateEditor.data.slice(0, -1).concat({ 226 | ...stateEditor.data[stateEditor.data.length - 1], 227 | previewIndex: 0, 228 | } as unknown as Token), 229 | }, publicState) 230 | } 231 | } else { 232 | setState(component, name, { 233 | expression: stateEditor.data, 234 | }, publicState) 235 | } 236 | } 237 | 238 | getTokens(component: Component, name: Properties, publicState: boolean): Token[] { 239 | const state = getState(component, name, publicState) 240 | if(!state || !state.expression) return [] 241 | return state.expression 242 | .filter(token => token && typeof token === 'object' && token.type) // Filter out invalid tokens 243 | .map(token => fromStored(token, component.getId())) 244 | } 245 | } 246 | 247 | if(!window.customElements.get('properties-editor')) { 248 | window.customElements.define('properties-editor', PropertiesEditor) 249 | } 250 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { Component } from 'grapesjs' 19 | import { TemplateResult } from 'lit' 20 | import { Button } from 'grapesjs' 21 | 22 | 23 | export interface DataSourceEditorViewOptions { 24 | el?: HTMLElement | string | undefined | (() => HTMLElement) 25 | settingsEl?: HTMLElement | string | (() => HTMLElement) 26 | button?: Button | (() => Button) 27 | styles?: string 28 | optionsStyles?: string 29 | defaultFixed?: boolean 30 | disableStates?: boolean 31 | disableAttributes?: boolean 32 | disableProperties?: boolean 33 | previewDebounceDelay?: number, 34 | previewRefreshEvents?: string, // used in tests 35 | } 36 | 37 | /** 38 | * Options for the DataSourceEditor plugin 39 | */ 40 | export interface DataSourceEditorOptions { 41 | dataSources: IDataSourceOptions[], 42 | view: DataSourceEditorViewOptions, 43 | filters: Filter[] | string, 44 | previewActive: boolean, 45 | } 46 | 47 | // Queries 48 | export type PageId = string // GrapesJs page id type 49 | export interface Query { 50 | expression: Expression 51 | } 52 | 53 | /** 54 | * Tree structure for creating query from components states 55 | */ 56 | export interface Tree { 57 | token: Property 58 | children: Tree[] 59 | } 60 | 61 | // Data sources must implement this interface 62 | export type DataSourceId = string | number 63 | export interface IDataSource { 64 | // For reference in expressions 65 | id: DataSourceId 66 | 67 | // Basic properties 68 | label: string 69 | url: string 70 | type: DataSourceType 71 | method?: string 72 | headers?: Record 73 | 74 | // Hide from users settings 75 | hidden?: boolean 76 | readonly?: boolean 77 | 78 | // Initialization 79 | connect(): Promise 80 | isConnected(): boolean 81 | 82 | // Introspection 83 | getTypes(): Type[] 84 | getQueryables(): Field[] 85 | getQuery(trees: Tree[]): string 86 | 87 | // Access data 88 | fetchValues(query: string): Promise 89 | 90 | // Event handling 91 | on?(event: any, callback?: any, context?: any): any 92 | off?(event?: any, callback?: any, context?: any): any 93 | trigger?(event: any, ...args: unknown[]): any 94 | } 95 | export const FIXED_TOKEN_ID = 'fixed' 96 | 97 | export const DATA_SOURCE_READY = 'data-source:ready' 98 | export const DATA_SOURCE_ERROR = 'data-source:error' 99 | export const DATA_SOURCE_CHANGED = 'data-source:changed' 100 | export const COMPONENT_STATE_CHANGED = 'component:state:changed' 101 | export const DATA_SOURCE_DATA_LOAD_START = 'data-source:data-load:start' 102 | export const DATA_SOURCE_DATA_LOAD_END = 'data-source:data-load:end' 103 | export const DATA_SOURCE_DATA_LOAD_CANCEL= 'data-source:data-load:cancel' 104 | 105 | export const PREVIEW_RENDER_START = 'data-source:start:preview' 106 | export const PREVIEW_RENDER_END = 'data-source:start:end' 107 | export const PREVIEW_RENDER_ERROR = 'data-source:start:error' 108 | 109 | export const PREVIEW_ACTIVATED = 'data-source:preview:activated' 110 | export const PREVIEW_DEACTIVATED = 'data-source:preview:deactivated' 111 | 112 | export const COMMAND_REFRESH = 'data-source:refresh' 113 | export const COMMAND_PREVIEW_ACTIVATE = 'data-source:preview:activate' 114 | export const COMMAND_PREVIEW_DEACTIVATE = 'data-source:preview:deactivate' 115 | export const COMMAND_PREVIEW_REFRESH = 'data-source:preview:refresh' 116 | export const COMMAND_PREVIEW_TOGGLE = 'data-source:preview:toggle' 117 | export const COMMAND_ADD_DATA_SOURCE = 'data-source:add' 118 | 119 | export type DataSourceType = 'graphql' 120 | 121 | // Options of a data source 122 | export interface IDataSourceOptions { 123 | id: DataSourceId 124 | label: string 125 | type: DataSourceType 126 | readonly?: boolean 127 | } 128 | 129 | // Types 130 | export type TypeId = string 131 | export type Type = { 132 | id: TypeId 133 | label: string 134 | fields: Field[] 135 | dataSourceId?: DataSourceId // Not required for builtin types 136 | } 137 | 138 | // From https://graphql.org/graphql-js/basic-types/ 139 | export const builtinTypeIds = ['String', 'Int', 'Float', 'Boolean', 'ID', 'Unknown'] 140 | export const builtinTypes: Type[] = builtinTypeIds.map(id => ({ 141 | id, 142 | label: id, 143 | fields: [], 144 | })) 145 | 146 | // Fileds 147 | export type FieldId = string 148 | export type FieldKind = 'scalar' | 'object' | 'list' | 'unknown' 149 | export interface FieldArgument { 150 | name: string 151 | typeId: TypeId 152 | defaultValue?: unknown 153 | } 154 | export interface Field { 155 | id: FieldId 156 | label: string 157 | typeIds: TypeId[] 158 | kind: FieldKind 159 | dataSourceId?: DataSourceId 160 | arguments?: FieldArgument[] 161 | previewIndex?: number 162 | previewGroup?: number 163 | } 164 | 165 | // ** 166 | // Data tree 167 | /** 168 | * A token can be a property or a filter 169 | */ 170 | export type Token = Property | Filter | State 171 | 172 | /** 173 | * Stored tokens are how the tokens are stored in the component as JSON 174 | * Use DataTree#fromStored to convert them back to tokens 175 | */ 176 | export type StoredToken = StoredProperty | StoredFilter | State 177 | export type Options = Record 178 | 179 | /** 180 | * A property is used to make expressions and access data from the data source 181 | */ 182 | export interface BaseProperty { 183 | type: 'property' 184 | propType: /*'type' |*/ 'field' 185 | dataSourceId?: DataSourceId 186 | } 187 | 188 | export type PropertyOptions = Record 189 | export interface StoredProperty extends BaseProperty { 190 | typeIds: TypeId[] 191 | fieldId: FieldId 192 | label: string 193 | kind: FieldKind 194 | options?: PropertyOptions 195 | previewIndex?: number 196 | previewGroup?: number 197 | } 198 | export interface Property extends StoredProperty { 199 | optionsForm?: (selected: Component, input: Field | null, options: Options, stateName: string) => TemplateResult | null 200 | } 201 | 202 | /** 203 | * A filter is used to alter data in an expression 204 | * It is provided in the options 205 | */ 206 | export type FilterId = string 207 | export interface StoredFilter { 208 | type: 'filter' 209 | id: FilterId 210 | filterName?: FilterId // Override the id for the filter name if present 211 | label: string 212 | options: Options 213 | quotedOptions?: string[] 214 | optionsKeys?: string[] // Optional, used to set a specific order 215 | previewIndex?: number 216 | previewGroup?: number 217 | } 218 | export interface Filter extends StoredFilter { 219 | optionsForm?: (selected: Component, input: Field | null, options: Options, stateName: string) => TemplateResult | null 220 | validate: (input: Field | null) => boolean 221 | output: (input: Field | null, options: Options) => Field | null 222 | apply: (input: unknown, options: Options) => unknown 223 | } 224 | 225 | /** 226 | * A component state 227 | */ 228 | export type StateId = string 229 | export interface State { 230 | type: 'state' 231 | storedStateId: StateId // Id of the state stored in the component 232 | previewIndex?: number 233 | previewGroup?: number 234 | label: string 235 | componentId: string 236 | exposed: boolean 237 | forceKind?: FieldKind 238 | } 239 | 240 | ///** 241 | // * A fixed value 242 | // */ 243 | //export interface Fixed extends Step { 244 | // type: 'fixed' 245 | // options: { 246 | // value: string 247 | // inpuType: FixedType 248 | // } 249 | //} 250 | 251 | /** 252 | * A context is a list of available tokens for a component 253 | */ 254 | export type Context = Token[] 255 | 256 | 257 | /** 258 | * An expression is a list of tokens which can be evaluated to a value 259 | * It is used to access data from the data source 260 | */ 261 | export type Expression = StoredToken[] 262 | 263 | /** 264 | * Operators for condition in visibility property 265 | */ 266 | export enum UnariOperator { 267 | TRUTHY = 'truthy', 268 | FALSY = 'falsy', 269 | EMPTY_ARR = 'empty array', 270 | NOT_EMPTY_ARR = 'not empty array', 271 | } 272 | 273 | /** 274 | * Operators for condition in visibility property 275 | */ 276 | export enum BinaryOperator { 277 | EQUAL = '==', 278 | NOT_EQUAL = '!=', 279 | GREATER_THAN = '>', 280 | LESS_THAN = '<', 281 | GREATER_THAN_OR_EQUAL = '>=', 282 | LESS_THAN_OR_EQUAL = '<=', 283 | } 284 | 285 | /** 286 | * Properties of elements 287 | * What is not a property is an attribute or a state 288 | */ 289 | export enum Properties { 290 | innerHTML = 'innerHTML', 291 | condition = 'condition', 292 | condition2 = 'condition2', 293 | __data = '__data', 294 | } 295 | 296 | export interface ComponentExpression { 297 | expression: Expression 298 | component: Component 299 | } 300 | -------------------------------------------------------------------------------- /src/model/dataSourceManager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { COMPONENT_STATE_CHANGED, DATA_SOURCE_CHANGED, DATA_SOURCE_ERROR, DATA_SOURCE_READY, Filter, IDataSource, DataSourceEditorOptions, DataSourceId, Type, Field } from '../types' 19 | 20 | export const STANDARD_TYPES: Type[] = [ 21 | { 22 | id: 'string', 23 | label: 'String', 24 | fields: [], 25 | }, 26 | { 27 | id: 'number', 28 | label: 'Number', 29 | fields: [], 30 | }, 31 | { 32 | id: 'boolean', 33 | label: 'Boolean', 34 | fields: [], 35 | }, 36 | { 37 | id: 'date', 38 | label: 'Date', 39 | fields: [], 40 | }, 41 | { 42 | id: 'unknown', 43 | label: 'Unknown', 44 | fields: [], 45 | }, 46 | ] 47 | import { Component, Editor } from 'grapesjs' 48 | import { StoredState, onStateChange } from './state' 49 | import { 50 | initializeDataSourceRegistry, 51 | getAllDataSources, 52 | addDataSource as registryAddDataSource, 53 | removeDataSource as registryRemoveDataSource, 54 | setDataSources, 55 | dataSourcesToJSON, 56 | } from './dataSourceRegistry' 57 | import { initializePreviewDataLoader, loadPreviewData } from './previewDataLoader' 58 | import { initializeFilters } from '../filters' 59 | import { validateFilters } from '../filters' 60 | 61 | /** 62 | * Data source manager state 63 | */ 64 | export interface DataSourceManagerState { 65 | dataSources: IDataSource[] 66 | filters: Filter[] 67 | previewData: Record 68 | readonly editor: Editor 69 | options: DataSourceEditorOptions 70 | cachedTypes: Type[] 71 | cachedQueryables: Field[] 72 | eventListeners: { 73 | dataChangedBinded: (e?: CustomEvent) => void 74 | dataSourceReadyBinded: (ds: IDataSource) => void 75 | dataSourceErrorBinded: (message: string, ds: IDataSource) => void 76 | } 77 | } 78 | 79 | // Global manager instance 80 | let globalManager: DataSourceManagerState | null = null 81 | 82 | /** 83 | * Get all types from all connected data sources 84 | */ 85 | function getAllTypes(manager: DataSourceManagerState): Type[] { 86 | return manager.dataSources 87 | .filter(ds => ds.isConnected()) 88 | .flatMap(ds => ds.getTypes()) 89 | .concat(STANDARD_TYPES) 90 | } 91 | 92 | /** 93 | * Get all queryable fields from all connected data sources 94 | */ 95 | function getAllQueryables(manager: DataSourceManagerState): Field[] { 96 | return manager.dataSources 97 | .filter(ds => ds.isConnected()) 98 | .flatMap(ds => ds.getQueryables()) 99 | } 100 | 101 | /** 102 | * Update cached types and queryables 103 | */ 104 | function updateCachedData(): void { 105 | const manager = getManager() 106 | 107 | manager.cachedTypes = getAllTypes(manager) 108 | manager.cachedQueryables = getAllQueryables(manager) 109 | } 110 | 111 | /** 112 | * Initialize the global data source manager 113 | */ 114 | export function initializeDataSourceManager( 115 | dataSources: IDataSource[], 116 | editor: Editor, 117 | options: DataSourceEditorOptions 118 | ): void { 119 | const filters = initializeFilters(editor, options) 120 | 121 | // Validate filters 122 | validateFilters(filters) 123 | 124 | // Initialize the registry 125 | initializeDataSourceRegistry(editor) 126 | 127 | // Set initial data sources 128 | setDataSources(dataSources) 129 | 130 | // Get current data sources 131 | const currentDataSources = getAllDataSources() 132 | 133 | // Initialize preview data loader 134 | initializePreviewDataLoader(editor) 135 | 136 | // Create bound event handlers 137 | const eventListeners = { 138 | dataChangedBinded: (e?: CustomEvent) => { 139 | editor.trigger(DATA_SOURCE_CHANGED, e?.detail) 140 | }, 141 | dataSourceReadyBinded: (ds: IDataSource) => { 142 | updateCachedData() // Update cache when data source becomes ready 143 | editor.trigger(DATA_SOURCE_READY, ds) 144 | loadPreviewData(true) // force refresh when data source becomes ready 145 | }, 146 | dataSourceErrorBinded: (message: string, ds: IDataSource) => { 147 | editor.trigger(DATA_SOURCE_ERROR, message, ds) 148 | }, 149 | } 150 | 151 | globalManager = { 152 | editor, 153 | options, 154 | previewData: {}, 155 | cachedTypes: [], 156 | cachedQueryables: [], 157 | dataSources: currentDataSources, 158 | filters, 159 | eventListeners, 160 | } 161 | 162 | // Set up event listeners 163 | setupEventListeners() 164 | 165 | // Listen for data source changes and re-setup event listeners 166 | editor.on(DATA_SOURCE_CHANGED, () => { 167 | setupEventListeners() 168 | updateCachedData() 169 | refreshDataSources() 170 | }) 171 | 172 | // Update cached data initially 173 | updateCachedData() 174 | 175 | // Relay state changes to the editor 176 | onStateChange((state: StoredState | null, component: Component) => { 177 | loadPreviewData().then(() => editor.trigger(COMPONENT_STATE_CHANGED, { state, component })) 178 | }) 179 | } 180 | 181 | /** 182 | * Get the global manager (throws if not initialized) 183 | */ 184 | export function getManager(): DataSourceManagerState { 185 | if (!globalManager) { 186 | throw new Error('DataSourceManager not initialized. Call initializeDataSourceManager first.') 187 | } 188 | return globalManager 189 | } 190 | 191 | /** 192 | * Add a data source 193 | */ 194 | export async function addDataSource(dataSource: IDataSource): Promise { 195 | registryAddDataSource(dataSource) 196 | setupEventListeners() 197 | } 198 | 199 | /** 200 | * Remove a data source 201 | */ 202 | export function removeDataSource(dataSource: IDataSource): void { 203 | registryRemoveDataSource(dataSource) 204 | setupEventListeners() 205 | loadPreviewData(true) // force refresh when data source is removed 206 | } 207 | 208 | /** 209 | * Refresh all data sources data 210 | */ 211 | export function refreshDataSources(): void { 212 | loadPreviewData(true) // force refresh when explicitly requested 213 | } 214 | 215 | /** 216 | * Reset all data sources 217 | */ 218 | export function resetDataSources(dataSources: IDataSource[]): void { 219 | setDataSources(dataSources) 220 | setupEventListeners() 221 | } 222 | 223 | 224 | /** 225 | * Get filters 226 | */ 227 | export function getFilters(): Filter[] { 228 | return getManager().filters 229 | } 230 | 231 | /** 232 | * Set filters 233 | */ 234 | export function setFilters(filters: Filter[]): void { 235 | getManager().filters = filters 236 | updateCachedData() 237 | } 238 | 239 | /** 240 | * Get preview data 241 | */ 242 | export function getPreviewData(): Record { 243 | return getManager().previewData 244 | } 245 | 246 | /** 247 | * Set preview data 248 | */ 249 | export function setPreviewData(data: Record): void { 250 | getManager().previewData = data 251 | } 252 | 253 | /** 254 | * Get all types from all data sources 255 | */ 256 | export function getTypes(): Type[] { 257 | return getAllTypes(getManager()) 258 | } 259 | 260 | /** 261 | * Get all queryable fields from all data sources 262 | */ 263 | export function getQueryables(): Field[] { 264 | return getAllQueryables(getManager()) 265 | } 266 | 267 | /** 268 | * Get cached types 269 | */ 270 | export function getCachedTypes(): Type[] { 271 | return getManager().cachedTypes 272 | } 273 | 274 | /** 275 | * Get cached queryables 276 | */ 277 | export function getCachedQueryables(): Field[] { 278 | return getManager().cachedQueryables 279 | } 280 | 281 | /** 282 | * Convert to JSON for storage 283 | */ 284 | export function toJSON(): unknown[] { 285 | return dataSourcesToJSON() 286 | } 287 | 288 | /** 289 | * Set up event listeners on all data sources 290 | */ 291 | function setupEventListeners(): void { 292 | const manager = getManager() 293 | const dataSources = getAllDataSources() 294 | 295 | // Update the manager with current data sources 296 | manager.dataSources = [...dataSources] 297 | 298 | // Remove all listeners 299 | dataSources.forEach((dataSource: IDataSource) => { 300 | if (typeof dataSource.off === 'function') { 301 | dataSource.off(DATA_SOURCE_READY, manager.eventListeners.dataSourceReadyBinded) 302 | dataSource.off(DATA_SOURCE_CHANGED, manager.eventListeners.dataChangedBinded) 303 | dataSource.off(DATA_SOURCE_ERROR, manager.eventListeners.dataSourceErrorBinded) 304 | } 305 | }) 306 | 307 | // Add listeners on all data sources 308 | dataSources.forEach((dataSource: IDataSource) => { 309 | if (typeof dataSource.on === 'function') { 310 | dataSource.on(DATA_SOURCE_READY, manager.eventListeners.dataSourceReadyBinded) 311 | dataSource.on(DATA_SOURCE_CHANGED, manager.eventListeners.dataChangedBinded) 312 | dataSource.on(DATA_SOURCE_ERROR, manager.eventListeners.dataSourceErrorBinded) 313 | } 314 | }) 315 | } 316 | -------------------------------------------------------------------------------- /integration-tests/visibility-truthy/website.json: -------------------------------------------------------------------------------- 1 | { 2 | "dataSources": [], 3 | "assets": [], 4 | "styles": [ 5 | { 6 | "selectors": [ 7 | "#idqd" 8 | ], 9 | "style": {} 10 | }, 11 | { 12 | "selectors": [ 13 | "#ivs7" 14 | ], 15 | "style": {} 16 | }, 17 | { 18 | "selectors": [ 19 | "#idzn" 20 | ], 21 | "style": {} 22 | }, 23 | { 24 | "selectors": [ 25 | "#iqiqn" 26 | ], 27 | "style": {} 28 | }, 29 | { 30 | "selectors": [ 31 | "#icaf" 32 | ], 33 | "wrapper": 1, 34 | "style": {} 35 | } 36 | ], 37 | "pages": [ 38 | { 39 | "frames": [ 40 | { 41 | "component": { 42 | "type": "wrapper", 43 | "stylable": [ 44 | "background", 45 | "background-color", 46 | "background-image", 47 | "background-repeat", 48 | "background-attachment", 49 | "background-position", 50 | "background-size" 51 | ], 52 | "attributes": { 53 | "id": "icaf" 54 | }, 55 | "components": [ 56 | { 57 | "attributes": { 58 | "id": "idqd" 59 | }, 60 | "components": [ 61 | { 62 | "tagName": "h1", 63 | "type": "text", 64 | "attributes": { 65 | "id": "idzn" 66 | }, 67 | "components": [ 68 | { 69 | "type": "textnode", 70 | "content": "South Americax" 71 | } 72 | ], 73 | "privateStates": [ 74 | { 75 | "id": "innerHTML", 76 | "expression": [ 77 | { 78 | "type": "state", 79 | "storedStateId": "__data", 80 | "componentId": "idqd-4124", 81 | "previewIndex": 0, 82 | "exposed": false, 83 | "forceKind": "object", 84 | "label": "Loop data (Continent)" 85 | }, 86 | { 87 | "type": "property", 88 | "propType": "field", 89 | "fieldId": "name", 90 | "label": "name", 91 | "typeIds": [ 92 | "String" 93 | ], 94 | "dataSourceId": "countries", 95 | "kind": "scalar", 96 | "options": {} 97 | } 98 | ] 99 | } 100 | ] 101 | }, 102 | { 103 | "tagName": "p", 104 | "type": "text", 105 | "attributes": { 106 | "id": "ivs7" 107 | }, 108 | "components": [ 109 | { 110 | "type": "textnode", 111 | "content": "For this test I configured the plugin with a demo country API:\n https://studio.apollographql.com/public/countries/variant/current/home" 112 | } 113 | ], 114 | "privateStates": [ 115 | { 116 | "id": "innerHTML", 117 | "expression": [ 118 | { 119 | "type": "property", 120 | "propType": "field", 121 | "fieldId": "fixed", 122 | "label": "Fixed value", 123 | "kind": "scalar", 124 | "typeIds": [ 125 | "String" 126 | ], 127 | "options": { 128 | "value": "=======================" 129 | } 130 | } 131 | ] 132 | }, 133 | { 134 | "id": "condition", 135 | "expression": [ 136 | { 137 | "type": "state", 138 | "storedStateId": "__data", 139 | "componentId": "idqd-4124", 140 | "previewIndex": 0, 141 | "exposed": false, 142 | "forceKind": "object", 143 | "label": "Loop data (Continent)" 144 | }, 145 | { 146 | "type": "property", 147 | "propType": "field", 148 | "fieldId": "name", 149 | "label": "name", 150 | "typeIds": [ 151 | "String" 152 | ], 153 | "dataSourceId": "countries", 154 | "kind": "scalar", 155 | "options": {} 156 | } 157 | ] 158 | }, 159 | { 160 | "id": "condition2", 161 | "expression": [ 162 | { 163 | "type": "property", 164 | "propType": "field", 165 | "fieldId": "fixed", 166 | "label": "Fixed value", 167 | "kind": "scalar", 168 | "typeIds": [ 169 | "String" 170 | ], 171 | "options": { 172 | "value": "Africa" 173 | } 174 | } 175 | ] 176 | } 177 | ], 178 | "conditionOperator": "==" 179 | }, 180 | { 181 | "tagName": "p", 182 | "type": "text", 183 | "attributes": { 184 | "id": "iqiqn" 185 | }, 186 | "components": [ 187 | { 188 | "type": "textnode", 189 | "content": "This plugin lets you set \"states\" on components with the data coming from the API" 190 | } 191 | ] 192 | } 193 | ], 194 | "privateStates": [ 195 | { 196 | "id": "__data", 197 | "expression": [ 198 | { 199 | "options": { 200 | "filter": "{}" 201 | }, 202 | "type": "property", 203 | "propType": "field", 204 | "fieldId": "continents", 205 | "label": "continents", 206 | "typeIds": [ 207 | "Continent" 208 | ], 209 | "dataSourceId": "countries", 210 | "kind": "list", 211 | "previewIndex": 6 212 | } 213 | ] 214 | }, 215 | { 216 | "id": "condition", 217 | "expression": [ 218 | { 219 | "type": "state", 220 | "storedStateId": "__data", 221 | "componentId": "idqd-4124", 222 | "previewIndex": 6, 223 | "exposed": false, 224 | "forceKind": "object", 225 | "label": "Loop data (Continent)" 226 | }, 227 | { 228 | "type": "property", 229 | "propType": "field", 230 | "fieldId": "name", 231 | "label": "name", 232 | "typeIds": [ 233 | "String" 234 | ], 235 | "dataSourceId": "countries", 236 | "kind": "scalar", 237 | "options": {}, 238 | "previewIndex": 6 239 | } 240 | ] 241 | } 242 | ], 243 | "id-plugin-data-source": "idqd-4124", 244 | "conditionOperator": "truthy" 245 | } 246 | ], 247 | "head": { 248 | "type": "head" 249 | }, 250 | "docEl": { 251 | "tagName": "html" 252 | } 253 | }, 254 | "id": "4tWZTq4IM0e4mQtN" 255 | } 256 | ], 257 | "id": "l7ddUccrV1wxCVP7" 258 | } 259 | ], 260 | "symbols": [] 261 | } 262 | -------------------------------------------------------------------------------- /src/model/ExpressionTree.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import { Page } from 'grapesjs' 19 | import { ComponentExpression, DataSourceId, Expression, IDataSource, Property, Tree } from '../types' 20 | import { getStates } from './state' 21 | import { getOptionObject } from './token' 22 | import { getComponentDebug, NOTIFICATION_GROUP, toExpression } from '../utils' 23 | import { resolveStateExpression } from './expressionEvaluator' 24 | import { DataSourceManagerState } from './dataSourceManager' 25 | 26 | 27 | 28 | // Pure functions for data operations 29 | 30 | 31 | 32 | /** 33 | * Get all expressions used in a page 34 | */ 35 | export function getPageExpressions(manager: DataSourceManagerState, page: Page): ComponentExpression[] { 36 | const result: ComponentExpression[] = [] 37 | const mainComponent = page.getMainComponent() 38 | if (mainComponent) { 39 | mainComponent.onAll(component => { 40 | // Get expressions used by the component from states 41 | const states = getStates(component, true).concat(getStates(component, false)) 42 | states.forEach(state => { 43 | if(state.expression) { 44 | result.push({ 45 | expression: state.expression, 46 | component, 47 | }) 48 | } 49 | }) 50 | // Get expressions used by the component from attributes 51 | Object.values(component.getAttributes()).forEach((value: string) => { 52 | const expression = toExpression(value) 53 | if(expression) { 54 | result.push({ 55 | expression, 56 | component, 57 | }) 58 | } 59 | }) 60 | }) 61 | } 62 | return result 63 | } 64 | 65 | /** 66 | * Build a tree of expressions 67 | */ 68 | export function getTrees( 69 | manager: DataSourceManagerState, 70 | {expression, component}: ComponentExpression, 71 | dataSourceId: DataSourceId 72 | ): Tree[] { 73 | if(expression.length === 0) return [] 74 | const next = expression[0] 75 | switch(next.type) { 76 | case 'property': { 77 | if(next.dataSourceId !== dataSourceId) return [] 78 | const trees = getTrees(manager, {expression: expression.slice(1), component}, dataSourceId) 79 | if(trees.length === 0) return [{ 80 | token: next, 81 | children: [], 82 | }] 83 | return trees 84 | .flatMap(tree => { 85 | // Check if this is a "relative" property or "absolute" (a root type) 86 | if(isRelative(manager, next, tree.token, dataSourceId)) { 87 | return { 88 | token: next, 89 | children: [tree], 90 | } 91 | } else { 92 | return [{ 93 | token: next, 94 | children: [], 95 | }, tree] 96 | } 97 | }) 98 | } 99 | case 'filter': { 100 | const options = Object.values(next.options) 101 | .map((value: unknown) => toExpression(value)) 102 | .filter((exp: Expression | null) => !!exp && exp.length > 0) 103 | .flatMap(exp => getTrees(manager, {expression: exp!, component}, dataSourceId)) 104 | 105 | const trees = getTrees(manager, {expression: expression.slice(1), component}, dataSourceId) 106 | if(trees.length === 0) return options 107 | return trees.flatMap(tree => [tree, ...options]) 108 | } 109 | case 'state': { 110 | const resolved = resolveStateExpression(next, component, manager) 111 | if(!resolved) { 112 | manager.editor.runCommand('notifications:add', { 113 | type: 'error', 114 | group: NOTIFICATION_GROUP, 115 | message: `Unable to resolve state
${JSON.stringify(next)}
`, 116 | componentId: component.getId(), 117 | }) 118 | throw new Error(`Unable to resolve state ${JSON.stringify(next)}. State defined on component ${getComponentDebug(component)}`) 119 | } 120 | return getTrees(manager, {expression: resolved, component}, dataSourceId) 121 | } 122 | default: 123 | manager.editor.runCommand('notifications:add', { 124 | type: 'error', 125 | group: NOTIFICATION_GROUP, 126 | message: `Invalid expression
${JSON.stringify(expression)}
`, 127 | componentId: component.getId(), 128 | }) 129 | throw new Error(`Invalid expression ${JSON.stringify(expression)}. Expression used on component ${getComponentDebug(component)}`) 130 | } 131 | } 132 | 133 | /** 134 | * Check if a property is relative to a type 135 | */ 136 | export function isRelative( 137 | manager: DataSourceManagerState, 138 | parent: Property, 139 | child: Property, 140 | dataSourceId: DataSourceId 141 | ): boolean { 142 | const ds = manager.dataSources.find((dataSource: IDataSource) => dataSource.id === dataSourceId) 143 | if(!ds) throw new Error(`Data source not found ${dataSourceId}`) 144 | if(!ds.isConnected()) throw new Error(`Data source ${dataSourceId} is not ready (not connected)`) 145 | const parentTypes = ds.getTypes().filter(t => parent.typeIds.includes(t.id)) 146 | const parentFieldsTypes = parentTypes.flatMap(t => t.fields.map(f => f.typeIds).flat()) 147 | return parentFieldsTypes.length > 0 && child.typeIds.some(typeId => parentFieldsTypes.includes(typeId)) 148 | } 149 | 150 | /** 151 | * From expressions to a tree 152 | */ 153 | export function toTrees( 154 | manager: DataSourceManagerState, 155 | expressions: ComponentExpression[], 156 | dataSourceId: DataSourceId 157 | ): Tree[] { 158 | if(expressions.length === 0) return [] 159 | return expressions 160 | // From Expression to Tree 161 | .flatMap(expression => getTrees(manager, expression, dataSourceId)) 162 | // Group by root token 163 | .reduce((acc: Tree[][], tree: Tree) => { 164 | const existing = acc.find(t => t[0].token.fieldId === tree.token.fieldId && (!tree.token.dataSourceId || t[0].token.dataSourceId === tree.token.dataSourceId)) 165 | if(existing) { 166 | existing.push(tree) 167 | } else { 168 | acc.push([tree]) 169 | } 170 | return acc 171 | }, [] as Tree[][]) 172 | // Merge all trees from the root 173 | .map((grouped: Tree[]) => { 174 | try { 175 | const merged = grouped.reduce((acc, tree) => mergeTrees(acc, tree)) 176 | return merged 177 | } catch(e) { 178 | manager.editor.runCommand('notifications:add', { 179 | type: 'error', 180 | group: NOTIFICATION_GROUP, 181 | message: `Unable to merge trees
${JSON.stringify(grouped)}
`, 182 | componentId: expressions[0].component.getId(), 183 | }) 184 | throw e 185 | } 186 | }) 187 | } 188 | 189 | /** 190 | * Recursively merge two trees 191 | */ 192 | export function mergeTrees(tree1: Tree, tree2: Tree): Tree { 193 | // Check if the trees have the same fieldId 194 | if (tree1.token.dataSourceId !== tree2.token.dataSourceId 195 | // Don't check for kind because it can be different for the same fieldId 196 | // For example `blog` collection (kind: list) for a loop/repeat 197 | // and `blog` item (kind: object) from inside the loop 198 | // FIXME: why is this? 199 | // || tree1.token.kind !== tree2.token.kind 200 | ) { 201 | console.error('Unable to merge trees', tree1, tree2) 202 | throw new Error(`Unable to build GraphQL query: unable to merge trees ${JSON.stringify(tree1)} and ${JSON.stringify(tree2)}`) 203 | } 204 | 205 | // Check if there are children with the same fieldId but different options 206 | // FIXME: we should use graphql aliases: https://graphql.org/learn/queries/#aliases but then it changes the variable name in the result 207 | const errors = tree1.children 208 | .filter(child1 => tree2.children.find(child2 => 209 | child1.token.fieldId === child2.token.fieldId 210 | && getOptionObject(child1.token.options, child2.token.options).error 211 | )) 212 | .map(child1 => { 213 | const child2 = tree2.children.find(child2 => child1.token.fieldId === child2.token.fieldId) 214 | return `${child1.token.fieldId} appears twice with different options: ${JSON.stringify(child1.token.options)} vs ${JSON.stringify(child2?.token.options)}` 215 | }) 216 | 217 | if(errors.length > 0) { 218 | console.error('Unable to merge trees', errors) 219 | throw new Error(`Unable to build GraphQL query: unable to merge trees: \n* ${errors.join('\n* ')}`) 220 | } 221 | 222 | const different = tree1.children 223 | .filter(child1 => !tree2.children.find(child2 => 224 | child1.token.fieldId === child2.token.fieldId 225 | && child1.token.typeIds.join(',') === child2.token.typeIds.join(',') 226 | && !getOptionObject(child1.token.options, child2.token.options).error 227 | )) 228 | .concat(tree2.children 229 | .filter(child2 => !tree1.children.find(child1 => 230 | child1.token.fieldId === child2.token.fieldId 231 | && child1.token.typeIds.join(',') === child2.token.typeIds.join(',') 232 | && !getOptionObject(child1.token.options, child2.token.options).error 233 | )) 234 | ) 235 | const same = tree1.children 236 | .filter(child1 => tree2.children.find(child2 => 237 | child1.token.fieldId === child2.token.fieldId 238 | && child1.token.typeIds.join(',') === child2.token.typeIds.join(',') 239 | && !getOptionObject(child1.token.options, child2.token.options).error 240 | )) 241 | 242 | return { 243 | token: tree1.token, 244 | children: different 245 | .concat(same 246 | .map(child1 => { 247 | const child2 = tree2.children.find(child2 => child1.token.fieldId === child2.token.fieldId) 248 | return mergeTrees(child1, child2!) 249 | })), 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/view/defaultStyles.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | export const OPTIONS_STYLES = ` 19 | form { 20 | display: flex; 21 | flex-direction: column; 22 | align-items: stretch; 23 | padding: 5px; 24 | color: var(--ds-tertiary); 25 | min-width: 300px; 26 | } 27 | form label { 28 | text-align: left; 29 | margin-top: 5px; 30 | } 31 | form .buttons { 32 | display: flex; 33 | justify-content: flex-end; 34 | margin: 5px 0; 35 | width: 100%; 36 | } 37 | form input { 38 | padding: 4px; 39 | background-color: transparent; 40 | border-radius: 2px; 41 | color: var(--ds-tertiary); 42 | border: 1px solid var(--ds-tertiary); 43 | } 44 | form .buttons input { 45 | margin-left: 5px; 46 | cursor: pointer; 47 | padding: 4px 10px; 48 | background-color: var(--ds-button-bg); 49 | } 50 | form .buttons input[type="reset"] { 51 | border-color: transparent; 52 | } 53 | form .buttons input:hover { 54 | color: var(--ds-primary); 55 | } 56 | form input.ds-expression-input__fixed { 57 | color: black; 58 | } 59 | ` 60 | export const PROPERTY_STYLES = ` 61 | :root { 62 | --ds-primary: #8873FE; 63 | --ds-secondary: #E5E5E5; 64 | --ds-tertiary: #1D1D1D; 65 | --ds-highlight: #8873FE; 66 | --ds-lowlight: #252525; 67 | --ds-button-color: #E5E5E5; 68 | --ds-button-bg: #252525; 69 | --ds-button-border: var(--ds-button-bg); 70 | 71 | --expression-input-dirty-background-color: var(--ds-button-bg); 72 | --expression-input-dirty-border-color: var(--ds-tertiary); 73 | --expression-input-dirty-color: var(--ds-highlight); 74 | --expression-input-active-color: var(--ds-tertiary); 75 | --expression-input-active-background-color: var(--ds-secondary); 76 | --popin-dialog-background: var(--ds-secondary); 77 | --popin-dialog-color: var(--ds-tertiary); 78 | --popin-dialog-header-background: transparent; 79 | --popin-dialog-body-background: transparent; 80 | --popin-dialog-footer-background: transparent; 81 | --expression-input-placeholder-margin: 0 10px; 82 | --expression-input-item-button-margin: 0; 83 | --expression-input-item-button-padding: 2px; 84 | --expression-input-item-button-border-radius: 50%; 85 | --expression-input-item-button-width: 20px; 86 | --expression-input-item-button-height: 20px; 87 | --expression-input-item-button-background-color: transparent; 88 | --expression-input-item-button-color: var(--ds-button-color); 89 | --expression-input-separator-color: var(--ds-button-color); 90 | --expression-input-separator-font-size: 0.7em; 91 | --expression-input-separator-margin: 0; 92 | --expression-input-separator-padding: 0 3px 0 1px; 93 | --expression-input-item-arrow-padding: 5px 5px 0 5px; 94 | --expression-input-values-li-icon-margin-right: 0; 95 | /* 96 | --popin-dialog-header-color: #333; 97 | --popin-dialog-body-color: #666; 98 | --popin-dialog-footer-color: #333; 99 | --popin-dialog-header-border-bottom: none; 100 | --popin-dialog-footer-border-top: none; 101 | --popin-dialog-header-padding: 0; 102 | --popin-dialog-body-padding: 5px; 103 | --popin-dialog-footer-padding: 0; 104 | */ 105 | } 106 | .ds-state-editor__options { 107 | --ds-secondary: #1A1A1A; 108 | --ds-tertiary: #F5F5F5; 109 | --ds-lowlight: #E0E0E0; 110 | --ds-button-color: #1A1A1A; 111 | --ds-button-bg: #FFFFFF; 112 | --expression-input-dirty-background-color: var(--ds-button-bg); 113 | --expression-input-dirty-border-color: var(--ds-tertiary); 114 | --expression-input-dirty-color: var(--ds-highlight); 115 | --expression-input-active-color: var(--ds-tertiary); 116 | --expression-input-active-background-color: var(--ds-secondary); 117 | } 118 | .gjs-traits-label { 119 | font-family: "Ubuntu", sans-serif; 120 | font-size: 0.85rem; 121 | padding: 9px 10px 9px 20px; 122 | text-align: left; 123 | display: flex; 124 | justify-content: space-between; 125 | align-items: center; 126 | min-height: 40px; 127 | } 128 | expression-input { 129 | padding: 10px; 130 | display: block; 131 | } 132 | expression-input::part(separator__delete) { 133 | border-right: 1px solid var(--ds-button-border); 134 | height: 20px; 135 | } 136 | expression-input::part(add-button) { 137 | background-color: var(--ds-tertiary); 138 | border-radius: 2px; 139 | padding: 3px; 140 | margin: 0; 141 | border: 1px solid var(--ds-tertiary); 142 | width: 24px; 143 | height: 24px; 144 | box-sizing: border-box; 145 | cursor: pointer; 146 | } 147 | expression-input::part(delete-button) { 148 | margin: 0; 149 | padding: 0; 150 | display: flex; 151 | justify-content: center; 152 | color: var(--ds-button); 153 | } 154 | expression-input::part(header) { 155 | border: none; 156 | } 157 | expression-input::part(type) { 158 | padding-bottom: 0; 159 | padding-top: 4px; 160 | display: none; 161 | } 162 | expression-input::part(name) { 163 | font-weight: normal; 164 | padding-bottom: 0; 165 | padding-top: 0; 166 | padding-left: 5px; 167 | } 168 | expression-input::part(property-input) { 169 | padding: 4px; 170 | border: medium; 171 | flex: 1 1 auto; 172 | background-color: transparent; 173 | color: var(--ds-secondary); 174 | } 175 | expression-input::part(property-container) { 176 | background-color: var(--ds-tertiary); 177 | border-radius: 2px; 178 | box-sizing: border-box; 179 | padding: 5px; 180 | margin: 5px 0; 181 | } 182 | expression-input::part(scroll-container) { 183 | overflow: auto; 184 | box-sizing: border-box; 185 | 186 | /* inner shadow to make it visible when content is overflowing */ 187 | box-shadow: inset 0 0 5px 0 rgba(0,0,0,.3); 188 | 189 | } 190 | expression-input::part(steps-container) { 191 | display: flex; 192 | align-items: center; 193 | background-color: var(--ds-button-bg); 194 | border-radius: 2px; 195 | padding: 5px; 196 | margin: 5px 0; 197 | width: max-content; 198 | min-width: 100%; 199 | box-sizing: border-box; 200 | } 201 | expression-input::part(dirty-icon) { 202 | cursor: pointer; 203 | margin: 0 10px; 204 | color: var(--ds-highlight); 205 | } 206 | expression-input::part(dirty-icon) { 207 | color: var(--ds-highlight); 208 | vertical-align: bottom; 209 | display: inline-flex; 210 | margin: 0; 211 | margin-left: 20px; 212 | } 213 | expression-input::part(expression-input-item) { 214 | border: 1px solid var(--ds-tertiary); 215 | background-color: var(--ds-tertiary); 216 | border-radius: 2px; 217 | margin-right: 5px; 218 | } 219 | .ds-section { 220 | &:last-child { 221 | margin-bottom: 100px; 222 | } 223 | details { 224 | margin: 2px; 225 | padding: 2px; 226 | background-color: transparent; 227 | border-radius: 2px; 228 | color: var(--ds-secondary); 229 | text-align: left; 230 | } 231 | details[open] { 232 | background-color: var(--ds-tertiary); 233 | } 234 | details summary { 235 | color: var(--ds-secondary); 236 | cursor: pointer; 237 | padding: 10px 0; 238 | } 239 | details a { 240 | color: var(--ds-link-color); 241 | } 242 | details .ds-states__help-link { 243 | display: block; 244 | } 245 | details .ds-states__help--tooltip { 246 | position: absolute; 247 | left: 50%; 248 | background: var(--ds-secondary); 249 | color: var(--ds-tertiary); 250 | padding: 10px; 251 | } 252 | .gjs-traits-label { 253 | background-color: var(--ds-tertiary); 254 | span { 255 | display: flex; 256 | align-items: center; 257 | } 258 | } 259 | main { 260 | display: flex; 261 | flex-direction: column; 262 | } 263 | .ds-slot-fixed { 264 | width: 100%; 265 | } 266 | select { 267 | width: 150px; 268 | flex: 0; 269 | margin: 5px; 270 | padding: 5px; 271 | background-color: var(--ds-button-bg); 272 | border-radius: 2px; 273 | color: var(--ds-secondary); 274 | border: 1px solid var(--ds-tertiary); 275 | cursor: pointer; 276 | font-size: medium; 277 | } 278 | input.ds-expression-input__fixed { 279 | color: var(--ds-secondary); 280 | padding: 10px; 281 | border: none; 282 | background-color: transparent; 283 | width: 100%; 284 | box-sizing: border-box; 285 | } 286 | .ds-expression-input__add { 287 | max-width: 40px; 288 | text-align: center; 289 | font-size: large; 290 | padding-right: 9px; 291 | -webkit-appearance: none; 292 | -moz-appearance: none; 293 | text-indent: 1px; 294 | text-overflow: ''; 295 | } 296 | .ds-expression-input__add option { 297 | font-size: medium; 298 | } 299 | .ds-expression-input__options-button { 300 | background-color: transparent; 301 | border: none; 302 | color: var(--ds-secondary); 303 | cursor: pointer; 304 | padding: 0; 305 | margin: 10px; 306 | margin-left: 0; 307 | } 308 | label.ds-label { 309 | display: flex; 310 | align-items: center; 311 | padding: 10px; 312 | color: var(--ds-secondary); 313 | } 314 | label.ds-label--disabled { 315 | justify-content: space-between; 316 | } 317 | label.ds-label--disabled .ds-label__message { 318 | opacity: .5; 319 | } 320 | select.ds-visibility__condition-operator { 321 | margin: 10px; 322 | } 323 | } 324 | /* States CSS Styles */ 325 | .ds-states { 326 | display: flex; 327 | flex-direction: column; 328 | } 329 | .ds-states__buttons { 330 | display: flex; 331 | flex-direction: row; 332 | justify-content: flex-end; 333 | margin: 0 5px; 334 | } 335 | .ds-states__button { 336 | cursor: pointer; 337 | border: 1px solid var(--ds-button-border); 338 | border-radius: 2px; 339 | padding: 5px; 340 | background: var(--ds-button-bg); 341 | color: var(--ds-button-color); 342 | margin: 5px; 343 | padding: 7px 14px; 344 | } 345 | .ds-states__button--disabled { 346 | opacity: 0.5; 347 | cursor: default; 348 | } 349 | .ds-states__remove-button { 350 | margin-left: 1em; 351 | } 352 | .ds-states__sep { 353 | width: 100%; 354 | border: none; 355 | height: 1px; 356 | background: var(--ds-button-bg); 357 | } 358 | /* real data */ 359 | .ds-real-data { 360 | code { 361 | overflow: hidden; 362 | text-wrap: nowrap; 363 | display: block; 364 | padding: 0 10px; 365 | text-overflow: ellipsis; 366 | margin-top: -5px; 367 | margin-bottom: 10px; 368 | text-align: right; 369 | } 370 | } 371 | ` 372 | -------------------------------------------------------------------------------- /src/model/token.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'grapesjs' 2 | import { Expression, Field, FieldArgument, Filter, Options, Property, PropertyOptions, StoredToken, Token, Type } from '../types' 3 | import { getFilters, getManager } from './dataSourceManager' 4 | import { NOTIFICATION_GROUP } from '../utils' 5 | import { getParentByPersistentId, getState } from './state' 6 | import { TemplateResult, html } from 'lit' 7 | 8 | /** 9 | * Add missing methonds to the filter 10 | * When filters are stored they lose their methods 11 | * @throws Error if the filter is not found 12 | */ 13 | export function getFilterFromToken(token: Filter, filters: Filter[]): Filter { 14 | if(token.type !== 'filter') throw new Error('Token is not a filter') 15 | const filter = filters.find(filter => filter.id === token.id) 16 | if (!filter) { 17 | console.error('Filter not found', token) 18 | throw new Error(`Filter ${token.id} not found`) 19 | } 20 | return { 21 | ...token, 22 | ...filter, 23 | // Keep the options as they are stored 24 | options: token.options, 25 | } 26 | } 27 | 28 | /** 29 | * Get the token from its stored form 30 | * @throws Error if the token type is not found 31 | */ 32 | export function fromStored(token: StoredToken, componentId: string | null): T { 33 | // Handle invalid tokens - return null or throw a descriptive error 34 | if (!token || typeof token !== 'object') { 35 | console.error('Invalid token: not an object', token) 36 | throw new Error('Invalid token: expected an object') 37 | } 38 | 39 | if (!token.type) { 40 | console.error('Invalid token: missing type property', token) 41 | throw new Error('Invalid token: missing type property') 42 | } 43 | 44 | switch (token.type) { 45 | case 'filter': { 46 | if ((token as Filter).optionsForm) return token as T 47 | const original = getFilters().find(filter => filter.id === token.id) as T | undefined 48 | if (!original) { 49 | console.error('Filter not found', token) 50 | throw new Error(`Filter ${token.id} not found`) 51 | } 52 | return { 53 | ...original, 54 | ...token, 55 | type: 'filter', 56 | } as T 57 | } 58 | case 'property': { 59 | if ((token as Property).optionsForm) return token as T 60 | const field = propertyToField(token, componentId) 61 | if (!field) { 62 | console.error('Field not found for token', token) 63 | throw new Error(`Field ${token.fieldId} not found`) 64 | } 65 | return { 66 | ...getTokenOptions(field) ?? {}, 67 | ...token, 68 | type: 'property', 69 | propType: 'field', 70 | } as T 71 | } 72 | case 'state': 73 | return token as T 74 | default: 75 | console.error('Unknown token type (reading type)', token) 76 | throw new Error('Unknown token type') 77 | } 78 | } 79 | 80 | /** 81 | * Get the type corresponding to a token 82 | */ 83 | export function tokenToField(token: Token, prev: Field | null, component: Component): Field | null { 84 | switch (token.type) { 85 | case 'filter': { 86 | try { 87 | const filter = getFilterFromToken(token, getFilters()) 88 | try { 89 | if (filter.validate(prev)) { 90 | return filter.output(prev, filter.options ?? {}) 91 | } 92 | } catch (e) { 93 | console.warn('Filter validate error:', e, {token, prev}) 94 | return null 95 | } 96 | return null 97 | } catch { 98 | // FIXME: notify user 99 | console.error('Error while getting filter result type', {token, prev, component}) 100 | return null 101 | } 102 | } 103 | case 'property': 104 | try { 105 | return propertyToField(token, component.getId()) 106 | } catch { 107 | // FIXME: notify user 108 | console.error('Error while getting property result type', {token, component}) 109 | return null 110 | } 111 | case 'state': { 112 | const parent = getParentByPersistentId(token.componentId, component) 113 | if (!parent) { 114 | console.warn('Component not found for state', token) 115 | const manager = getManager() 116 | manager.editor.runCommand('notifications:add', { 117 | type: 'error', 118 | group: NOTIFICATION_GROUP, 119 | message: `Component not found for state: ${token.storedStateId}`, 120 | componentId: component.getId(), 121 | }) 122 | return null 123 | } 124 | const expression = getState(parent, token.storedStateId, token.exposed)?.expression 125 | if (!expression) { 126 | console.warn('State is not defined on component', { component: parent, token }) 127 | const manager = getManager() 128 | manager.editor.runCommand('notifications:add', { 129 | type: 'error', 130 | group: NOTIFICATION_GROUP, 131 | message: `State '${token.storedStateId}' is not defined on component '${parent.getName() || parent.get('id')}'`, 132 | componentId: parent.getId(), 133 | }) 134 | return null 135 | } 136 | try { 137 | const field = getExpressionResultType(expression, parent) 138 | return field ? { 139 | ...field, 140 | kind: token.forceKind ?? field.kind, 141 | } : null 142 | } catch { 143 | // FIXME: notify user 144 | console.error('Error while getting expression result type in tokenToField', {expression, parent, component, token, prev}) 145 | return null 146 | } 147 | } 148 | default: 149 | console.error('Unknown token type (reading type)', token) 150 | throw new Error('Unknown token type') 151 | } 152 | } 153 | 154 | /** 155 | * Get the type corresponding to a property 156 | * @throws Error if the type is not found 157 | */ 158 | export function propertyToField(property: Property, componentId: string | null): Field { 159 | const manager = getManager() 160 | const allTypes = manager.cachedTypes.length > 0 ? manager.cachedTypes : [] 161 | 162 | const typeNames: string[] = [] 163 | 164 | // Check each typeId and handle missing types 165 | for (const typeId of property.typeIds) { 166 | const type: Type | undefined = allTypes.find(type => type.id === typeId && (!property.dataSourceId || type.dataSourceId === property.dataSourceId)) 167 | if (!type) { 168 | // Show notification for missing type, similar to main branch DataTree.getType 169 | manager.editor.runCommand('notifications:add', { 170 | type: 'error', 171 | group: NOTIFICATION_GROUP, 172 | message: `Type not found ${property.dataSourceId ?? ''}.${typeId}`, 173 | componentId, 174 | }) 175 | console.error(`Type not found ${property.dataSourceId ?? ''}.${typeId}`) 176 | // Continue processing other types instead of throwing 177 | } else { 178 | typeNames.push(type.label) 179 | } 180 | } 181 | 182 | const args = property.options ? Object.entries(property.options).map(([name, value]) => ({ 183 | typeId: 'JSON', // FIXME: Why is this hardcoded? 184 | name, 185 | defaultValue: value, // FIXME: Why is this value, it should keep the initial default 186 | })) : undefined 187 | 188 | return { 189 | id: property.fieldId, 190 | label: typeNames.length > 0 ? typeNames.join(', ') : property.label, 191 | typeIds: property.typeIds, 192 | kind: property.kind, 193 | dataSourceId: property.dataSourceId, 194 | arguments: args, 195 | previewIndex: property.previewIndex, 196 | } 197 | } 198 | 199 | /** 200 | * Evaluate the types of each token in an expression 201 | */ 202 | export function expressionToFields(expression: Expression, component: Component): Field[] { 203 | // Resolve type of the expression 1 step at a time 204 | let prev: Field | null = null 205 | const unknownField: Field = { 206 | id: 'unknown', 207 | label: 'unknown', 208 | typeIds: [], 209 | kind: 'scalar', 210 | } 211 | return expression.map((token) => { 212 | try { 213 | const field = tokenToField(fromStored(token, component.getId()), prev, component) 214 | if (!field) { 215 | console.warn('Type not found for token in expressionToFields', { token, expression }) 216 | return unknownField 217 | } 218 | prev = field 219 | return field 220 | } catch { 221 | // FIXME: notify user 222 | console.error('Error while getting expression result type in expressionToFields', {expression, component, token, prev}) 223 | return unknownField 224 | } 225 | }) 226 | } 227 | 228 | /** 229 | * Evaluate an expression to a type 230 | * This is used to validate expressions and for autocompletion 231 | * @throws Error if the token type is not found 232 | */ 233 | export function getExpressionResultType(expression: Expression, component: Component): Field | null { 234 | // Resolve type of the expression 1 step at a time 235 | return expression.reduce((prev: Field | null, token: StoredToken) => { 236 | return tokenToField(fromStored(token, component.getId()), prev, component) 237 | }, null) 238 | } 239 | 240 | /** 241 | * Get the options of a token 242 | */ 243 | export function getTokenOptions(field: Field): { optionsForm: (selected: Component, input: Field | null, options: Options) => TemplateResult, options: Options } | null { 244 | if (field.arguments && field.arguments.length > 0) { 245 | return { 246 | optionsForm: optionsToOptionsForm(field.arguments.map((arg) => ({ name: arg.name, value: arg.defaultValue }))), 247 | options: field.arguments.reduce((options: Record, arg: FieldArgument) => { 248 | options[arg.name] = arg.defaultValue 249 | return options 250 | }, {}), 251 | } 252 | } 253 | return null 254 | } 255 | 256 | /** 257 | * Get the options of a token or a field 258 | */ 259 | export function optionsToOptionsForm(arr: { name: string, value: unknown }[]): (selected: Component, input: Field | null, options: Options) => TemplateResult { 260 | return (selected: Component, input: Field | null, options: Options) => { 261 | return html` 262 | ${arr.map((obj) => { 263 | const value = options[obj.name] ?? obj.value ?? '' 264 | return html`` 265 | }) 266 | } 267 | ` 268 | } 269 | } 270 | 271 | /** 272 | * Utility function to shallow compare two objects 273 | * Used to compare options of tree items 274 | */ 275 | export function getOptionObject(option1: PropertyOptions | undefined, option2: PropertyOptions | undefined): { error: boolean, result: PropertyOptions | undefined } { 276 | // Handle the case where one or both are undefined or empty 277 | if(!option1 && !option2) return { error: false, result: undefined } 278 | if(isEmpty(option1) && isEmpty(option2)) return { error: false, result: undefined } 279 | // Handle the case where one is undefined or empty and the other is not 280 | if(!option1 || !option2) return { error: true, result: undefined } 281 | if(isEmpty(option1) || isEmpty(option2)) return { error: true, result: undefined } 282 | 283 | const keys1 = Object.keys(option1) 284 | const keys2 = Object.keys(option2) 285 | 286 | if (keys1.length !== keys2.length) { 287 | return { error: true, result: undefined } 288 | } 289 | 290 | for (const key of keys1) { 291 | if (option1[key] !== option2[key]) { 292 | return { error: true, result: undefined } 293 | } 294 | } 295 | 296 | return { error: false, result: option1 } 297 | } 298 | 299 | function isJson(str: string) { 300 | if(typeof str !== 'string') return false 301 | if(str.length === 0) return false 302 | try { 303 | JSON.parse(str) 304 | } catch { 305 | return false 306 | } 307 | return true 308 | } 309 | 310 | function isEmpty(value: unknown): boolean { 311 | if(value === null || typeof value === 'undefined') return true 312 | const isString = typeof value === 'string' 313 | const isJsonString = isString && isJson(value) 314 | if (isString && !isJsonString) return value === '' 315 | const json = isJsonString ? JSON.parse(value) : value 316 | if (Array.isArray(json)) return json.length === 0 317 | if (typeof json === 'object') return Object.values(json).filter(v => !!v).length === 0 318 | return false 319 | } 320 | 321 | export function buildArgs(options: PropertyOptions | undefined): string { 322 | const args = options ? `(${Object 323 | .keys(options) 324 | .map(key => ({ key, value: options![key] })) 325 | .filter(({ value }) => !isEmpty(value)) 326 | .map(({ key, value }) => { 327 | //return typeof value === 'string' && !isJson(value) ? `${key}: "${value}"` : `${key}: ${value}` 328 | return `${key}: ${value}` 329 | }) 330 | .join(', ') 331 | })` : '' 332 | // Valid args for GraphQL canot be just () 333 | const validArgs = args === '()' ? '' : args 334 | return validArgs 335 | } 336 | -------------------------------------------------------------------------------- /src/view/custom-states-editor.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Silex website builder, free/libre no-code tool for makers. 3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | import {LitElement, html} from 'lit' 19 | import { ref } from 'lit/directives/ref.js' 20 | import {property} from 'lit/decorators.js' 21 | import { StoredState, getState, getStateIds, removeState, setState } from '../model/state' 22 | import { Token } from '../types' 23 | 24 | import './state-editor' 25 | import { StateEditor } from './state-editor' 26 | import { Component, Editor } from 'grapesjs' 27 | import { PROPERTY_STYLES } from './defaultStyles' 28 | import { fromStored } from '../model/token' 29 | import { cleanStateName } from '../utils' 30 | 31 | interface Item { 32 | name: string 33 | publicState?: boolean 34 | state: StoredState 35 | } 36 | 37 | /** 38 | * Editor for selected element's states 39 | * 40 | */ 41 | export class CustomStatesEditor extends LitElement { 42 | @property({type: Boolean}) 43 | disabled = false 44 | 45 | @property({type: Boolean, attribute: 'private-state'}) 46 | privateState = false 47 | 48 | @property({type: String}) 49 | title = 'Custom states' 50 | 51 | @property({type: Boolean, attribute: 'default-fixed'}) 52 | defaultFixed = false 53 | 54 | @property({type: String, attribute: 'create-prompt'}) 55 | createPrompt = 'Name this state' 56 | 57 | @property({type: String, attribute: 'rename-prompt'}) 58 | renamePrompt = 'Rename this state' 59 | 60 | @property({type: String, attribute: 'default-name'}) 61 | defaultName = 'New state' 62 | 63 | // This is a comma separated list of reserved names 64 | // Or an array of reserved names 65 | @property({type: String, attribute: 'reserved-names'}) 66 | get reservedNames() { return this._reservedNames } 67 | set reservedNames(value: string | string[]) { 68 | if(typeof value === 'string') this._reservedNames = value.split(',').map(s => s.trim()) 69 | else this._reservedNames = value 70 | } 71 | 72 | @property({type: Boolean, attribute: 'hide-loop-data'}) 73 | hideLoopData = false 74 | 75 | @property({type: String, attribute: 'help-text'}) 76 | helpText = '' 77 | 78 | @property({type: String, attribute: 'help-link'}) 79 | helpLink = '' 80 | 81 | private _reservedNames: string[] = [] 82 | private editor: Editor | null = null 83 | private redrawing = false 84 | 85 | setEditor(editor: Editor) { 86 | if (this.editor) { 87 | console.warn('property-editor setEditor already set') 88 | return 89 | } 90 | this.editor = editor 91 | 92 | // Update the UI when a page is added/renamed/removed 93 | this.editor.on('page', () => this.requestUpdate()) 94 | 95 | // Update the UI on component selection change 96 | this.editor.on('component:selected', () => this.requestUpdate()) 97 | 98 | // Update the UI on component change 99 | this.editor.on('component:update', () => this.requestUpdate()) 100 | } 101 | 102 | getHead(selected: Component | null) { 103 | return html` 104 | 107 | 108 |
109 |
110 |
111 | ${this.title} 112 | 113 | ${ selected ? html` 114 | 123 | ` : ''} 124 | ${this.helpText ? html` 125 |
126 | Help 127 |
128 | ${ this.helpText } 129 | ${this.helpLink ? html` 130 | \u{1F517} Read more... 135 | ` : ''} 136 |
137 |
138 | ` : ''} 139 |
140 |
141 |
142 |
143 | ` 144 | } 145 | 146 | override render() { 147 | super.render() 148 | this.redrawing = true 149 | const selected = this.editor?.getSelected() 150 | const empty = html` 151 | ${this.getHead(null)} 152 |

Select an element to edit its states

153 | ` 154 | if(!this.editor || this.disabled) { 155 | this.redrawing = false 156 | return html`` 157 | } 158 | if(!selected) { 159 | this.redrawing = false 160 | return empty 161 | } 162 | const items: Item[] = this.getStateIds(selected) 163 | .map(stateId => ({ 164 | name: stateId, 165 | publicState: !this.privateState, 166 | state: this.getState(selected, stateId)!, 167 | })) 168 | .filter(item => item.state && !item.state.hidden) 169 | const result = html` 170 | ${this.getHead(selected)} 171 |
172 |
173 | ${ items.length === 0 ? html` 174 |

Use the "+" button to add elements to this list

175 | ` : ''} 176 | ${items 177 | .map((item, index) => html` 178 |
179 | ${this.getStateEditor(selected, item.state.label || '', item.name)} 180 |
181 | 189 | 200 | 208 | 216 |
217 |
218 |
219 | `)} 220 |
221 |
222 | ` 223 | this.redrawing = false 224 | return result 225 | } 226 | 227 | /** 228 | * Get the states for this type of editor 229 | */ 230 | getStateIds(component: Component): string[] { 231 | return getStateIds(component, !this.privateState) 232 | // Filter out the states which are properties 233 | .filter(stateId => !this.reservedNames.includes(stateId)) 234 | } 235 | 236 | /** 237 | * Get the states for this type of editor 238 | */ 239 | getState(component: Component, name: string): StoredState | null { 240 | return getState(component, name, !this.privateState) 241 | } 242 | 243 | /** 244 | * Set the states for this type of editor 245 | */ 246 | setState(component: Component, name: string, state: StoredState) { 247 | setState(component, name, state, !this.privateState) 248 | } 249 | 250 | /** 251 | * Remove the states for this type of editor 252 | */ 253 | removeState(component: Component, name: string) { 254 | removeState(component, name, !this.privateState) 255 | } 256 | 257 | getStateEditor(selected: Component, label: string, name: string) { 258 | return html` 259 | { 267 | if (el) { 268 | const stateEditor = el as StateEditor 269 | stateEditor.data = this.getTokens(selected, name) 270 | } 271 | })} 272 | @change=${() => this.onChange(selected, name, label)} 273 | .disabled=${this.disabled} 274 | > 275 | 276 | 277 | ` 278 | } 279 | 280 | onChange(component: Component, name: string, label: string) { 281 | if(this.redrawing) return 282 | const stateEditor = this.shadowRoot!.querySelector(`#${name}`) as StateEditor 283 | this.setState(component, name, { 284 | expression: stateEditor.data, 285 | label, 286 | }) 287 | } 288 | 289 | getTokens(component: Component, name: string): Token[] { 290 | const state = this.getState(component, name) 291 | if(!state || !state.expression) return [] 292 | return state.expression.map(token => { 293 | try { 294 | return fromStored(token, component.getId()) 295 | } catch { 296 | // FIXME: notify user 297 | console.error('Error while getting expression result type in getTokens', {expression: state.expression, component, name}) 298 | return { 299 | type: 'property', 300 | propType: 'field', 301 | fieldId: 'unknown', 302 | label: 'unknown', 303 | kind: 'scalar', 304 | typeIds: [], 305 | } 306 | } 307 | }) 308 | } 309 | 310 | /** 311 | * Rename a custom state 312 | */ 313 | renameCustomState(item: Item): Item { 314 | const label = prompt(this.renamePrompt, item.state.label) 315 | ?.toLowerCase() 316 | ?.replace(/[^a-z0-9]/g, '-') 317 | ?.replace(/^-+|-+$/g, '') 318 | if (!label || label === item.state.label) return item 319 | return { 320 | ...item, 321 | state: { 322 | ...item.state, 323 | label: label, 324 | }, 325 | } 326 | } 327 | 328 | /** 329 | * Update the custom states, in the order of the list 330 | */ 331 | updateOrderCustomStates(component: Component, items: Item[]) { 332 | const stateIds = this.getStateIds(component) 333 | // Remove all states 334 | stateIds.forEach(stateId => { 335 | if(items.map(item => item.name).includes(stateId)) { 336 | this.removeState(component, stateId) 337 | } 338 | }) 339 | // Add states in the order of the list 340 | items.forEach(item => { 341 | this.setState(component, item.name, item.state) 342 | }) 343 | } 344 | 345 | /** 346 | * Create a new custom state 347 | */ 348 | createCustomState(component: Component): Item | null { 349 | const label = cleanStateName(prompt(this.createPrompt, this.defaultName)) 350 | if (!label) return null 351 | 352 | if(this.reservedNames.includes(label)) { 353 | alert(`The name ${label} is reserved, please choose another name`) 354 | return null 355 | } 356 | const stateId = `${component.getId()}-${Math.random().toString(36).slice(2)}` 357 | const state: StoredState = { 358 | label, 359 | expression: [], 360 | } 361 | this.setState(component, stateId, state) 362 | return { 363 | name: stateId, 364 | state, 365 | publicState: !this.privateState, 366 | } 367 | } 368 | } 369 | 370 | if(!customElements.get('custom-states-editor')) { 371 | customElements.define('custom-states-editor', CustomStatesEditor) 372 | } 373 | --------------------------------------------------------------------------------