├── .dockerignore ├── .gitattributes ├── .github └── workflows │ ├── docker.yml │ └── publish-tag.yml ├── .gitignore ├── .gitmodules ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── angular.json ├── docker-compose.yml ├── docs └── img │ └── screenshot.png ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── eslint.config.js ├── karma.conf.js ├── local.Dockerfile ├── nginx └── default.conf.template ├── package-lock.json ├── package.json ├── projects └── editor │ ├── README.md │ ├── assets │ └── styles │ │ ├── styles.scss │ │ └── tailwind.scss │ ├── eslint.config.js │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── add-field-tree-item │ │ │ ├── add-field-tree-item.component.html │ │ │ ├── add-field-tree-item.component.scss │ │ │ ├── add-field-tree-item.component.spec.ts │ │ │ └── add-field-tree-item.component.ts │ │ ├── custom-formly │ │ │ ├── fieldconfig.cache.ts │ │ │ ├── formly-field │ │ │ │ ├── formly-field.component.html │ │ │ │ ├── formly-field.component.scss │ │ │ │ ├── formly-field.component.spec.ts │ │ │ │ └── formly-field.component.ts │ │ │ ├── formly-form │ │ │ │ ├── formly-form.component.html │ │ │ │ ├── formly-form.component.scss │ │ │ │ ├── formly-form.component.spec.ts │ │ │ │ └── formly-form.component.ts │ │ │ ├── formly-group │ │ │ │ ├── formly-group.component.html │ │ │ │ ├── formly-group.component.scss │ │ │ │ ├── formly-group.component.spec.ts │ │ │ │ └── formly-group.component.ts │ │ │ └── formly.template.ts │ │ ├── edit-field │ │ │ ├── edit-field.component.html │ │ │ ├── edit-field.component.scss │ │ │ ├── edit-field.component.spec.ts │ │ │ ├── edit-field.component.ts │ │ │ └── styles │ │ │ │ ├── style-option │ │ │ │ ├── style-option.component.html │ │ │ │ ├── style-option.component.scss │ │ │ │ ├── style-option.component.spec.ts │ │ │ │ └── style-option.component.ts │ │ │ │ ├── styles-config │ │ │ │ ├── bootstrap.styles-config.ts │ │ │ │ └── tailwind.styles-config.ts │ │ │ │ ├── styles.component.html │ │ │ │ ├── styles.component.scss │ │ │ │ ├── styles.component.spec.ts │ │ │ │ ├── styles.component.ts │ │ │ │ ├── styles.service.ts │ │ │ │ ├── styles.types.ts │ │ │ │ └── styles.utils.ts │ │ ├── editor.component.html │ │ ├── editor.component.scss │ │ ├── editor.component.spec.ts │ │ ├── editor.component.ts │ │ ├── editor.module.ts │ │ ├── editor.provider.ts │ │ ├── editor.service.spec.ts │ │ ├── editor.service.ts │ │ ├── editor.types.ts │ │ ├── editor.utils.ts │ │ ├── field-drag-drop │ │ │ ├── field-drag-drop.ts │ │ │ ├── field-drag-drop.types.ts │ │ │ └── field-drop-overlay │ │ │ │ ├── field-drop-overlay.component.html │ │ │ │ ├── field-drop-overlay.component.scss │ │ │ │ ├── field-drop-overlay.component.spec.ts │ │ │ │ └── field-drop-overlay.component.ts │ │ ├── field-name │ │ │ ├── field-name.pipe.spec.ts │ │ │ └── field-name.pipe.ts │ │ ├── field-service │ │ │ ├── field.service.ts │ │ │ └── field.service.utils.ts │ │ ├── field-tree-item │ │ │ ├── field-tree-item.component.html │ │ │ ├── field-tree-item.component.scss │ │ │ ├── field-tree-item.component.spec.ts │ │ │ └── field-tree-item.component.ts │ │ ├── file │ │ │ └── file.utils.ts │ │ ├── form │ │ │ ├── form.component.html │ │ │ ├── form.component.scss │ │ │ ├── form.component.spec.ts │ │ │ ├── form.component.ts │ │ │ ├── form.service.spec.ts │ │ │ ├── form.utils.ts │ │ │ └── toolbar │ │ │ │ ├── toolbar.component.html │ │ │ │ ├── toolbar.component.scss │ │ │ │ ├── toolbar.component.spec.ts │ │ │ │ └── toolbar.component.ts │ │ ├── json-dialog │ │ │ ├── json-dialog.component.html │ │ │ ├── json-dialog.component.scss │ │ │ ├── json-dialog.component.spec.ts │ │ │ ├── json-dialog.component.ts │ │ │ ├── json-dialog.types.ts │ │ │ └── json-validator │ │ │ │ ├── json-validator.directive.spec.ts │ │ │ │ └── json-validator.directive.ts │ │ ├── property │ │ │ ├── array-property │ │ │ │ ├── array-property.component.html │ │ │ │ ├── array-property.component.scss │ │ │ │ ├── array-property.component.ts │ │ │ │ └── array-property.types.ts │ │ │ ├── base-property.directive.ts │ │ │ ├── boolean-property │ │ │ │ ├── boolean-property.component.html │ │ │ │ ├── boolean-property.component.scss │ │ │ │ ├── boolean-property.component.spec.ts │ │ │ │ ├── boolean-property.component.ts │ │ │ │ └── boolean-property.types.ts │ │ │ ├── chip-list-property │ │ │ │ ├── chip-list-property.component.html │ │ │ │ ├── chip-list-property.component.scss │ │ │ │ ├── chip-list-property.component.spec.ts │ │ │ │ ├── chip-list-property.component.ts │ │ │ │ └── chip-list-property.types.ts │ │ │ ├── collection.property.directive.ts │ │ │ ├── expression-properties-property │ │ │ │ ├── expression-properties-property.component.html │ │ │ │ ├── expression-properties-property.component.scss │ │ │ │ ├── expression-properties-property.component.spec.ts │ │ │ │ ├── expression-properties-property.component.ts │ │ │ │ └── expression-properties-property.types.ts │ │ │ ├── input-property │ │ │ │ ├── input-property.component.html │ │ │ │ ├── input-property.component.scss │ │ │ │ ├── input-property.component.spec.ts │ │ │ │ ├── input-property.component.ts │ │ │ │ └── input-property.types.ts │ │ │ ├── object-property │ │ │ │ ├── object-property.component.html │ │ │ │ ├── object-property.component.scss │ │ │ │ ├── object-property.component.ts │ │ │ │ └── object-property.types.ts │ │ │ ├── property-key │ │ │ │ ├── property-key.component.html │ │ │ │ ├── property-key.component.scss │ │ │ │ ├── property-key.component.spec.ts │ │ │ │ └── property-key.component.ts │ │ │ ├── property.types.ts │ │ │ ├── property.utils.ts │ │ │ ├── select-property │ │ │ │ ├── select-property.component.html │ │ │ │ ├── select-property.component.scss │ │ │ │ ├── select-property.component.spec.ts │ │ │ │ ├── select-property.component.ts │ │ │ │ └── select-property.types.ts │ │ │ ├── textarea-property │ │ │ │ ├── textarea-property.component.html │ │ │ │ ├── textarea-property.component.scss │ │ │ │ ├── textarea-property.component.spec.ts │ │ │ │ ├── textarea-property.component.ts │ │ │ │ └── textarea-property.types.ts │ │ │ └── validators-property │ │ │ │ ├── validators-property.component.html │ │ │ │ ├── validators-property.component.scss │ │ │ │ ├── validators-property.component.ts │ │ │ │ └── validators-property.types.ts │ │ ├── sidebar │ │ │ ├── sidebar-section │ │ │ │ ├── sidebar-section.component.html │ │ │ │ ├── sidebar-section.component.scss │ │ │ │ ├── sidebar-section.component.spec.ts │ │ │ │ └── sidebar-section.component.ts │ │ │ ├── sidebar.component.html │ │ │ ├── sidebar.component.scss │ │ │ ├── sidebar.component.spec.ts │ │ │ ├── sidebar.component.ts │ │ │ └── sidebar.types.ts │ │ ├── state │ │ │ ├── state.actions.ts │ │ │ ├── state.reducers.ts │ │ │ ├── state.types.ts │ │ │ └── state.utils.ts │ │ ├── text-editor │ │ │ ├── text-editor.component.spec.ts │ │ │ └── text-editor.component.ts │ │ └── tree-item │ │ │ ├── tree-item.component.html │ │ │ ├── tree-item.component.scss │ │ │ ├── tree-item.component.spec.ts │ │ │ └── tree-item.component.ts │ ├── public-api.ts │ └── test.ts │ ├── tailwind.lib.config.js │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── src ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.config.ts │ ├── app.routes.ts │ ├── bootstrap │ │ ├── bootstrap.component.ts │ │ ├── bootstrap.config.ts │ │ ├── bootstrap.form.ts │ │ └── bootstrap.provider.ts │ └── material │ │ ├── components │ │ ├── card-wrapper │ │ │ ├── card-wrapper.component.html │ │ │ ├── card-wrapper.component.ts │ │ │ └── card-wrapper.provider.ts │ │ └── repeating-section-type │ │ │ ├── repeating-section-type.component.html │ │ │ ├── repeating-section-type.component.ts │ │ │ └── repeating-section-type.provider.ts │ │ ├── material.component.ts │ │ ├── material.config.ts │ │ ├── material.form.ts │ │ ├── material.provider.ts │ │ └── material.utils.ts ├── assets │ ├── .gitkeep │ └── img │ │ └── github-mark.svg ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.scss ├── test.ts └── theme.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | 4 | # dependencies 5 | /node_modules 6 | 7 | # misc 8 | /.angular/cache 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | projects/editor/assets/styles/tailwind.scss linguist-generated 2 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Login to Docker Hub 16 | uses: docker/login-action@v2 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_TOKEN }} 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v2 23 | 24 | - name: Build and push 25 | uses: docker/build-push-action@v4 26 | with: 27 | context: . 28 | file: ./Dockerfile 29 | push: true 30 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/formly-editor:latest 31 | -------------------------------------------------------------------------------- /.github/workflows/publish-tag.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '18.x' 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Build 25 | run: npm run build:editor 26 | 27 | - name: Publish 28 | run: cd ./dist/@sesan07/ngx-formly-editor && npm publish 29 | env: 30 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | .history/* 32 | 33 | # misc 34 | /.angular/cache 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesan07/formly-editor/ccc97493431b7345b5e59b985b9774034daffecc/.gitmodules -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.angular/cache 2 | /node_modules 3 | /projects/ngx-formly -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "trailingComma": "es5", 9 | "bracketSameLine": false, 10 | "singleAttributePerLine": true, 11 | "printWidth": 120, 12 | "overrides": [ 13 | { 14 | "files": "*.json", 15 | "options": { 16 | "tabWidth": 2 17 | } 18 | }, 19 | { 20 | "files": "*.yml", 21 | "options": { 22 | "tabWidth": 2 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "firefox", 9 | "request": "launch", 10 | "reAttach": true, 11 | "name": "Launch Firefox", 12 | "url": "http://localhost:4200", 13 | "webRoot": "${workspaceFolder}", 14 | "pathMappings": [ 15 | { 16 | "url": "webpack:///node_modules", 17 | "path": "${workspaceFolder}/node_modules" 18 | }, 19 | { 20 | "url": "webpack:///projects", 21 | "path": "${workspaceFolder}/projects" 22 | }, 23 | { 24 | "url": "webpack:///src", 25 | "path": "${workspaceFolder}/src" 26 | } 27 | ] 28 | }, 29 | { 30 | "type": "msedge", 31 | "request": "launch", 32 | "name": "Launch Edge", 33 | "url": "http://localhost:4200", 34 | "webRoot": "${workspaceFolder}" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.14-alpine AS build-env 2 | 3 | WORKDIR /app 4 | 5 | COPY ./package.json . 6 | COPY ./package-lock.json . 7 | RUN npm ci 8 | 9 | COPY ./projects/ ./projects 10 | COPY ./src ./src 11 | COPY ./angular.json . 12 | COPY ./tsconfig.json . 13 | COPY ./tsconfig.app.json . 14 | RUN npm run build 15 | 16 | FROM nginx:latest 17 | 18 | COPY --from=build-env /app/dist/formly-editor/browser /usr/share/nginx/html 19 | COPY ./nginx/default.conf.template /etc/nginx/templates/ 20 | 21 | EXPOSE 80 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 sesan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | formly-editor: 3 | container_name: formly-editor 4 | build: 5 | context: . 6 | dockerfile: local.Dockerfile 7 | volumes: 8 | - ./src:/app/src 9 | - ./projects:/app/projects 10 | ports: 11 | - '4200:4200' 12 | -------------------------------------------------------------------------------- /docs/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesan07/formly-editor/ccc97493431b7345b5e59b985b9774034daffecc/docs/img/screenshot.png -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: ['./src/**/*.e2e-spec.ts'], 13 | capabilities: { 14 | browserName: 'chrome', 15 | }, 16 | directConnect: true, 17 | baseUrl: 'http://localhost:4200/', 18 | framework: 'jasmine', 19 | jasmineNodeOpts: { 20 | showColors: true, 21 | defaultTimeoutInterval: 30000, 22 | print: function () {}, 23 | }, 24 | onPrepare() { 25 | require('ts-node').register({ 26 | project: require('path').join(__dirname, './tsconfig.json'), 27 | }); 28 | jasmine.getEnv().addReporter( 29 | new SpecReporter({ 30 | spec: { 31 | displayStacktrace: StacktraceOption.PRETTY, 32 | }, 33 | }) 34 | ); 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { browser, logging } from 'protractor'; 2 | import { AppPage } from './app.po'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('formly-editor app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs: any = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain( 20 | jasmine.objectContaining({ 21 | level: logging.Level.SEVERE, 22 | } as logging.Entry) 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo(): Promise { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText(): Promise { 9 | return element(by.css('app-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../out-tsc/e2e", 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "types": ["jasmine", "jasminewd2", "node"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // Allows us to bring in the recommended core rules from eslint itself 4 | const eslint = require('@eslint/js'); 5 | 6 | // Allows us to use the typed utility for our config, and to bring in the recommended rules for TypeScript projects from typescript-eslint 7 | const tseslint = require('typescript-eslint'); 8 | 9 | // Allows us to bring in the recommended rules for Angular projects from angular-eslint 10 | const angular = require('angular-eslint'); 11 | 12 | // Export our config array, which is composed together thanks to the typed utility function from typescript-eslint 13 | module.exports = tseslint.config( 14 | { 15 | // Everything in this config object targets our TypeScript files (Components, Directives, Pipes etc) 16 | files: ['**/*.ts'], 17 | extends: [ 18 | // Apply the recommended core rules 19 | eslint.configs.recommended, 20 | // Apply the recommended TypeScript rules 21 | ...tseslint.configs.recommended, 22 | // Optionally apply stylistic rules from typescript-eslint that improve code consistency 23 | ...tseslint.configs.stylistic, 24 | // Apply the recommended Angular rules 25 | ...angular.configs.tsRecommended, 26 | ], 27 | // Set the custom processor which will allow us to have our inline Component templates extracted 28 | // and treated as if they are HTML files (and therefore have the .html config below applied to them) 29 | processor: angular.processInlineTemplates, 30 | // Override specific rules for TypeScript files (these will take priority over the extended configs above) 31 | rules: { 32 | '@angular-eslint/directive-selector': [ 33 | 'error', 34 | { 35 | type: 'attribute', 36 | prefix: 'app', 37 | style: 'camelCase', 38 | }, 39 | ], 40 | '@angular-eslint/component-selector': [ 41 | 'error', 42 | { 43 | type: 'element', 44 | prefix: 'app', 45 | style: 'kebab-case', 46 | }, 47 | ], 48 | 49 | '@typescript-eslint/no-explicit-any': 'off', 50 | 51 | '@typescript-eslint/naming-convention': [ 52 | 'error', 53 | { 54 | selector: 'default', 55 | format: ['camelCase'], 56 | leadingUnderscore: 'allow', 57 | trailingUnderscore: 'allow', 58 | }, 59 | { 60 | selector: 'variable', 61 | format: ['camelCase', 'UPPER_CASE'], 62 | leadingUnderscore: 'allow', 63 | trailingUnderscore: 'allow', 64 | }, 65 | { 66 | selector: 'typeLike', 67 | format: ['PascalCase'], 68 | }, 69 | { 70 | selector: 'enumMember', 71 | format: ['UPPER_CASE'], 72 | }, 73 | { 74 | selector: 'memberLike', 75 | modifiers: ['private'], 76 | format: ['camelCase'], 77 | leadingUnderscore: 'require', 78 | }, 79 | ], 80 | 81 | 'no-shadow': 'off', 82 | 'no-underscore-dangle': 'off', 83 | }, 84 | }, 85 | { 86 | // Everything in this config object targets our HTML files (external templates, 87 | // and inline templates as long as we have the `processor` set on our TypeScript config above) 88 | files: ['**/*.html'], 89 | extends: [ 90 | // Apply the recommended Angular template rules 91 | ...angular.configs.templateRecommended, 92 | // // Apply the Angular template rules which focus on accessibility of our apps 93 | // ...angular.configs.templateAccessibility, 94 | ], 95 | rules: {}, 96 | } 97 | ); 98 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/formly-editor'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /local.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.14-alpine as build-env 2 | 3 | WORKDIR /app 4 | 5 | COPY ./package.json . 6 | COPY ./package-lock.json . 7 | RUN npm ci 8 | 9 | COPY ./projects/ ./projects 10 | COPY ./src ./src 11 | COPY ./angular.json . 12 | COPY ./tsconfig.json . 13 | COPY ./tsconfig.app.json . 14 | 15 | CMD [ "npm", "start" ] 16 | -------------------------------------------------------------------------------- /nginx/default.conf.template: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | 5 | server_name localhost; 6 | 7 | location = /health { 8 | access_log off; 9 | add_header 'Content-Type' 'text/plain'; 10 | return 200 'Status: OK!\n'; 11 | } 12 | 13 | location / { 14 | root /usr/share/nginx/html; 15 | index index.html; 16 | try_files $uri $uri/ /index.html; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formly-editor", 3 | "version": "0.0.0", 4 | "description": "A tool used to create/edit formly forms", 5 | "author": "Samuel Esan", 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "ng serve", 9 | "build": "ng build --configuration production", 10 | "build:editor": "ng build editor --configuration production", 11 | "test": "ng test", 12 | "e2e": "ng e2e", 13 | "lint:app": "ng lint formly-editor --fix", 14 | "lint:editor": "ng lint editor --fix", 15 | "pretty": "prettier --write \"./**/*.{ts,js,json,html}\"", 16 | "build-tailwind-css": "tailwindcss -c ./projects/editor/tailwind.lib.config.js -o ./projects/editor/assets/styles/tailwind.scss" 17 | }, 18 | "private": true, 19 | "dependencies": { 20 | "@angular/animations": "^19.2.7", 21 | "@angular/cdk": "^19.2.10", 22 | "@angular/common": "^19.2.7", 23 | "@angular/compiler": "^19.2.7", 24 | "@angular/core": "^19.2.7", 25 | "@angular/forms": "^19.2.7", 26 | "@angular/material": "^19.2.10", 27 | "@angular/material-moment-adapter": "^19.2.10", 28 | "@angular/platform-browser": "^19.2.7", 29 | "@angular/platform-browser-dynamic": "^19.2.7", 30 | "@angular/router": "^19.2.7", 31 | "@ng-dnd/core": "^4.0.0", 32 | "@ng-dnd/multi-backend": "^4.0.0", 33 | "@ngrx/store": "^19.1.0", 34 | "@ngx-formly/bootstrap": "^6.3.2", 35 | "@ngx-formly/core": "^6.3.2", 36 | "@ngx-formly/material": "^6.3.2", 37 | "bootstrap": "^5.3.3", 38 | "codemirror": "^5.65.16", 39 | "file-saver-es": "2.0.5", 40 | "immer": "^10.1.1", 41 | "lodash-es": "4.17.21", 42 | "nanoid": "^5.0.7", 43 | "rxjs": "^7.8.1", 44 | "tslib": "2.6.2", 45 | "zone.js": "0.15.0" 46 | }, 47 | "devDependencies": { 48 | "@angular-devkit/build-angular": "^19.2.8", 49 | "@angular/cli": "^19.2.8", 50 | "@angular/compiler-cli": "^19.2.7", 51 | "@angular/localize": "^19.2.7", 52 | "@eslint/eslintrc": "^3.3.1", 53 | "@eslint/js": "^9.25.0", 54 | "@types/codemirror": "^5.60.15", 55 | "@types/file-saver-es": "^2.0.3", 56 | "@types/jasmine": "~5.1.4", 57 | "@types/jasminewd2": "~2.0.13", 58 | "@types/lodash-es": "^4.17.12", 59 | "@types/node": "22.14.1", 60 | "angular-eslint": "19.3.0", 61 | "autoprefixer": "^10.4.19", 62 | "codelyzer": "6.0.2", 63 | "eslint": "^9.23.0", 64 | "jasmine-core": "~5.1.2", 65 | "jasmine-spec-reporter": "~7.0.0", 66 | "karma": "~6.4.3", 67 | "karma-chrome-launcher": "~3.2.0", 68 | "karma-coverage-istanbul-reporter": "~3.0.3", 69 | "karma-jasmine": "~5.1.0", 70 | "karma-jasmine-html-reporter": "2.1.0", 71 | "ng-packagr": "^19.2.2", 72 | "postcss": "^8.4.38", 73 | "prettier": "3.3.3", 74 | "protractor": "~7.0.0", 75 | "tailwindcss": "^3.4.3", 76 | "ts-node": "~10.9.2", 77 | "typescript": "5.8.3", 78 | "typescript-eslint": "8.27.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /projects/editor/README.md: -------------------------------------------------------------------------------- 1 | # Formly Editor 2 | 3 | A configurable editor for ngx-formly forms. 4 | 5 | Demo: https://formly-editor.sesan.dev 6 | 7 | Documentation: https://github.com/sesan07/formly-editor#readme 8 | -------------------------------------------------------------------------------- /projects/editor/assets/styles/styles.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use '@angular/material' as mat; 3 | 4 | @import 'codemirror/lib/codemirror'; 5 | @import 'codemirror/theme/elegant'; 6 | @import 'codemirror/addon/fold/foldgutter'; 7 | 8 | .CodeMirror { 9 | border-radius: 4px; 10 | } 11 | 12 | .CodeMirror-foldmarker { 13 | color: black; 14 | text-shadow: none; 15 | } 16 | 17 | .editor-mat { 18 | @include mat.icon-button-density(-4); 19 | @include mat.button-density(-1); 20 | @include mat.checkbox-density(-2); 21 | @include mat.form-field-density(-1); 22 | @include mat.form-field-overrides( 23 | ( 24 | filled-label-text-size: var(--mat-sys-body-medium-size), 25 | container-text-size: var(--mat-sys-body-medium-size), 26 | ) 27 | ); 28 | @include mat.select-overrides( 29 | ( 30 | trigger-text-size: var(--mat-sys-body-medium-size), 31 | ) 32 | ); 33 | @include mat.expansion-overrides( 34 | ( 35 | header-text-size: var(--mat-sys-body-medium-size), 36 | ) 37 | ); 38 | @include mat.tabs-overrides( 39 | ( 40 | divider-color: var(--mat-sys-outline-variant), 41 | ) 42 | ); 43 | 44 | .mat-form-field-appearance-outline { 45 | .mat-mdc-text-field-wrapper { 46 | padding: 0 8px; 47 | } 48 | 49 | .mat-mdc-form-field-infix { 50 | padding: 0.4em 0 !important; 51 | min-height: 0; 52 | line-height: 1; 53 | } 54 | } 55 | 56 | .mat-button-toggle-group { 57 | .mat-button-toggle { 58 | flex-basis: 50%; 59 | min-width: 120px; 60 | } 61 | 62 | .mat-button-toggle-label-content { 63 | line-height: 32px !important; 64 | } 65 | 66 | .mat-icon { 67 | margin-right: 4px; 68 | } 69 | } 70 | 71 | .mat-mdc-icon-button { 72 | line-height: normal; 73 | } 74 | 75 | .mat-mdc-option, 76 | .mat-mdc-menu-item { 77 | min-height: 40px; 78 | } 79 | } 80 | 81 | formly-field { 82 | display: block; 83 | } 84 | 85 | .editor-resizing { 86 | transition: none !important; 87 | } 88 | 89 | .tree-container { 90 | min-width: max-content; 91 | padding: 8px; 92 | box-sizing: border-box; 93 | } 94 | 95 | .tree-item { 96 | display: block; 97 | user-select: none; 98 | } 99 | 100 | .property-field { 101 | flex-grow: 1; 102 | } 103 | 104 | .cursor-pointer { 105 | cursor: pointer; 106 | } 107 | 108 | .expand-icon { 109 | color: var(--mat-sys-on-surface-variant); 110 | transition: transform 0.15s ease-out; 111 | } 112 | 113 | .expand-icon.expanded-90 { 114 | transform: rotate(90deg); 115 | } 116 | 117 | .expand-icon.expanded-180 { 118 | transform: rotate(-180deg); 119 | } 120 | 121 | .dialogBackdrop { 122 | background-color: white; 123 | opacity: 0.6 !important; 124 | } 125 | 126 | .editor-action-buttons { 127 | display: inline-flex; 128 | align-items: center; 129 | column-gap: 16px; 130 | } 131 | 132 | [editor-hidden='true'] { 133 | display: none !important; 134 | } 135 | -------------------------------------------------------------------------------- /projects/editor/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // Allows us to use the typed utility for our config 4 | const tseslint = require('typescript-eslint'); 5 | 6 | // Require our workspace root level config and extend from it 7 | const rootConfig = require('../../eslint.config.js'); 8 | 9 | module.exports = tseslint.config( 10 | // Apply the root config first 11 | ...rootConfig, 12 | { 13 | // Any project level overrides or additional rules for TypeScript files can go here 14 | // (we don't need to extend from any typescript-eslint or angular-eslint configs because 15 | // we already applied the rootConfig above which has them) 16 | files: ['**/*.ts'], 17 | rules: { 18 | '@angular-eslint/directive-selector': [ 19 | 'error', 20 | { 21 | type: 'attribute', 22 | prefix: 'editor', // different to our root config, which was "app" 23 | style: 'camelCase', 24 | }, 25 | ], 26 | '@angular-eslint/component-selector': [ 27 | 'error', 28 | { 29 | type: 'element', 30 | prefix: 'editor', // different to our root config, which was "app" 31 | style: 'kebab-case', 32 | }, 33 | ], 34 | '@typescript-eslint/no-explicit-any': 'off', 35 | '@typescript-eslint/naming-convention': [ 36 | 'error', 37 | { 38 | selector: 'default', 39 | format: ['camelCase'], 40 | leadingUnderscore: 'allow', 41 | trailingUnderscore: 'allow', 42 | }, 43 | { 44 | selector: 'variable', 45 | format: ['camelCase', 'UPPER_CASE'], 46 | leadingUnderscore: 'allow', 47 | trailingUnderscore: 'allow', 48 | }, 49 | { 50 | selector: 'typeLike', 51 | format: ['PascalCase'], 52 | }, 53 | { 54 | selector: 'enumMember', 55 | format: ['UPPER_CASE'], 56 | }, 57 | { 58 | selector: 'memberLike', 59 | modifiers: ['private'], 60 | format: ['camelCase'], 61 | leadingUnderscore: 'require', 62 | }, 63 | ], 64 | 'no-shadow': 'off', 65 | 'no-underscore-dangle': 'off', 66 | }, 67 | }, 68 | { 69 | // Any project level overrides or additional rules for HTML files can go here 70 | // (we don't need to extend from any angular-eslint configs because 71 | // we already applied the rootConfig above which has them) 72 | files: ['**/*.html'], 73 | rules: {}, 74 | } 75 | ); 76 | -------------------------------------------------------------------------------- /projects/editor/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage/editor'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /projects/editor/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/@sesan07/ngx-formly-editor", 4 | "assets": ["./assets"], 5 | "lib": { 6 | "entryFile": "src/public-api.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /projects/editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sesan07/ngx-formly-editor", 3 | "version": "0.3.0", 4 | "description": "A configurable editor for ngx-formly forms.", 5 | "author": "Samuel Esan (https://sesan.dev)", 6 | "license": "MIT", 7 | "homepage": "https://github.com/sesan07/formly-editor#readme", 8 | "peerDependencies": { 9 | "@angular/core": ">=18.0.0", 10 | "@angular/material": ">=18.0.0", 11 | "@ng-dnd/core": "^3.0.0", 12 | "@ng-dnd/multi-backend": "^3.0.0", 13 | "@ngrx/store": ">=18.0.0", 14 | "@ngx-formly/core": "^6.0.0", 15 | "@types/codemirror": "^5.60.7", 16 | "@types/file-saver-es": "^2.0.1", 17 | "@types/lodash-es": "^4.17.5", 18 | "codemirror": "^5.65.11", 19 | "file-saver-es": "^2.0.5", 20 | "immer": "^10.1.1", 21 | "lodash-es": "^4.17.21", 22 | "nanoid": "^5.0.7" 23 | }, 24 | "dependencies": { 25 | "tslib": "^2.0.0" 26 | }, 27 | "exports": { 28 | "./styles": { 29 | "sass": "./assets/styles/styles.scss" 30 | }, 31 | "./tailwind": { 32 | "sass": "./assets/styles/tailwind.scss" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /projects/editor/src/lib/add-field-tree-item/add-field-tree-item.component.html: -------------------------------------------------------------------------------- 1 | @if (isCategoryOption) { 2 | 8 | 9 | {{ fieldOption.displayName }} 10 | 11 | 12 | @for (child of childOptions; track child.displayName; let index = $index) { 13 | 17 | 18 | } 19 | 20 | 21 | } @else { 22 | 29 | 30 | {{ fieldOption.displayName }} 31 | 32 | 33 | } 34 | -------------------------------------------------------------------------------- /projects/editor/src/lib/add-field-tree-item/add-field-tree-item.component.scss: -------------------------------------------------------------------------------- 1 | .cursor-grab { 2 | cursor: grab; 3 | } 4 | -------------------------------------------------------------------------------- /projects/editor/src/lib/add-field-tree-item/add-field-tree-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AddFieldTreeItemComponent } from './add-field-tree-item.component'; 4 | 5 | describe('AddFieldTreeItemComponent', () => { 6 | let component: AddFieldTreeItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [AddFieldTreeItemComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(AddFieldTreeItemComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/add-field-tree-item/add-field-tree-item.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; 3 | import { DndService, DragSourceDirective } from '@ng-dnd/core'; 4 | import { BehaviorSubject } from 'rxjs'; 5 | 6 | import { EditorService } from '../editor.service'; 7 | import { DropAction, FieldOption, IEditorFormlyField } from '../editor.types'; 8 | import { isCategoryOption, isTypeOption } from '../editor.utils'; 9 | import { FieldDragDrop } from '../field-drag-drop/field-drag-drop'; 10 | import { TreeItemComponent } from '../tree-item/tree-item.component'; 11 | 12 | @Component({ 13 | selector: 'editor-add-field-tree-item', 14 | templateUrl: './add-field-tree-item.component.html', 15 | styleUrls: ['./add-field-tree-item.component.scss'], 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [TreeItemComponent, DragSourceDirective, AsyncPipe], 18 | }) 19 | export class AddFieldTreeItemComponent implements OnChanges, OnDestroy { 20 | @Input() public fieldOption: FieldOption; 21 | @Input() public isExpanded = false; 22 | @Input() public treeLevel = 0; 23 | 24 | public isExpanded$ = new BehaviorSubject(this.isExpanded); 25 | public isCategoryOption: boolean; 26 | public childOptions: FieldOption[] = []; 27 | 28 | public dnd: FieldDragDrop; 29 | 30 | private _field: IEditorFormlyField; 31 | 32 | constructor( 33 | private _editorService: EditorService, 34 | private _dndService: DndService 35 | ) { 36 | this.dnd = new FieldDragDrop(DropAction.COPY, this._editorService, this._dndService); 37 | } 38 | 39 | ngOnChanges(changes: SimpleChanges): void { 40 | if (changes.fieldOption) { 41 | if (isCategoryOption(this.fieldOption)) { 42 | this.isCategoryOption = true; 43 | this.childOptions = this.fieldOption.children; 44 | } else if (isTypeOption(this.fieldOption)) { 45 | this._field = this._editorService.getDefaultField(this.fieldOption.name) as IEditorFormlyField; 46 | this.dnd.setup(this._field); 47 | } 48 | } 49 | 50 | if (changes.isExpanded) { 51 | this.isExpanded$.next(this.isExpanded); 52 | } 53 | } 54 | 55 | ngOnDestroy(): void { 56 | this.dnd.destroy(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/fieldconfig.cache.ts: -------------------------------------------------------------------------------- 1 | import { ComponentRef, ElementRef, EmbeddedViewRef, Injector, ViewContainerRef } from '@angular/core'; 2 | import { AbstractControl, AsyncValidatorFn, UntypedFormArray, UntypedFormGroup, ValidatorFn } from '@angular/forms'; 3 | import { FieldType, FormlyExtension, FormlyFieldConfig, FormlyFormOptions } from '@ngx-formly/core'; 4 | import { Observable, Subscription } from 'rxjs'; 5 | 6 | export interface FormlyFieldConfigCache extends FormlyFieldConfig { 7 | form?: UntypedFormGroup | UntypedFormArray; 8 | model?: any; 9 | formControl?: AbstractControl & { 10 | _fields?: FormlyFieldConfigCache[]; 11 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 12 | _childrenErrors?: Record; 13 | }; 14 | parent?: FormlyFieldConfigCache; 15 | options?: FormlyFormOptionsCache; 16 | shareFormControl?: boolean; 17 | index?: number; 18 | _localFields?: FormlyFieldConfigCache[]; 19 | _elementRefs?: ElementRef[]; 20 | _expressions?: Record< 21 | string, 22 | { 23 | callback?: (ingoreCache: boolean) => boolean; 24 | paths?: string[]; 25 | subscription?: Subscription | null; 26 | value$?: Observable; 27 | } 28 | >; 29 | _hide?: boolean; 30 | _validators?: ValidatorFn[]; 31 | _asyncValidators?: AsyncValidatorFn[]; 32 | _componentRefs?: (ComponentRef | EmbeddedViewRef)[]; 33 | _proxyInstance?: FormlyExtension; 34 | _keyPath?: { 35 | key: FormlyFieldConfig['key']; 36 | path: string[]; 37 | }; 38 | } 39 | 40 | export interface FormlyFormOptionsCache extends FormlyFormOptions { 41 | checkExpressions?: (field: FormlyFieldConfig, ingoreCache?: boolean) => void; 42 | _viewContainerRef?: ViewContainerRef; 43 | _injector?: Injector; 44 | _hiddenFieldsForCheck?: { field: FormlyFieldConfigCache; default?: boolean }[]; 45 | _initialModel?: any; 46 | 47 | /** @deprecated */ 48 | _buildForm?: () => void; 49 | 50 | /** @deprecated */ 51 | _checkField?: (field: FormlyFieldConfig, ingoreCache?: boolean) => void; 52 | 53 | /** @deprecated */ 54 | _markForCheck?: (field: FormlyFieldConfig) => void; 55 | } 56 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-field/formly-field.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .editor-field-container { 6 | position: relative; 7 | transition: border-color 0.15 ease-out; 8 | } 9 | 10 | .editor-field-container.edit-mode { 11 | background-color: var(--mat-sys-surface-container-lowest); 12 | border: 2px solid; 13 | border-color: transparent; 14 | border-radius: 4px; 15 | } 16 | 17 | .editor-field-container.edit-mode:hover { 18 | border: 2px solid; 19 | border-color: var(--mat-sys-outline-variant); 20 | } 21 | 22 | .editor-field-container.edit-mode.active { 23 | border: 2px solid; 24 | border-color: var(--mat-sys-outline); 25 | } 26 | 27 | .editor-field-content.edit-mode { 28 | padding: 2px; 29 | } 30 | 31 | header { 32 | display: flex; 33 | user-select: none; 34 | border-radius: 2px 2px 0 0; 35 | padding: 1px 2px; 36 | box-sizing: border-box; 37 | background-color: var(--mat-sys-surface-container-low); 38 | } 39 | 40 | .header-title { 41 | font-size: var(--mat-sys-title-small-size); 42 | font-weight: var(--mat-sys-title-small-weight); 43 | flex-grow: 1; 44 | position: relative; 45 | display: flex; 46 | align-items: center; 47 | column-gap: 4px; 48 | cursor: grab; 49 | } 50 | 51 | .options { 52 | display: flex; 53 | column-gap: 4px; 54 | opacity: 0; 55 | transition: opacity 0.15s ease-out; 56 | 57 | &.visible { 58 | opacity: 1; 59 | } 60 | 61 | .mat-mdc-icon-button { 62 | height: 20px; 63 | width: 20px; 64 | line-height: 20px; 65 | padding: 0; 66 | 67 | .mat-icon { 68 | font-size: 16px; 69 | height: 18px; 70 | width: 18px; 71 | line-height: 18px; 72 | } 73 | 74 | ::ng-deep { 75 | .mat-mdc-button-touch-target { 76 | height: 20px; 77 | width: 20px; 78 | } 79 | } 80 | } 81 | } 82 | 83 | .delete-btn { 84 | margin-left: 8px; 85 | } 86 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-field/formly-field.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FormlyFieldComponent } from './formly-field.component'; 4 | 5 | describe('FormlyFieldComponent', () => { 6 | let component: FormlyFieldComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [FormlyFieldComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(FormlyFieldComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-form/formly-form.component.html: -------------------------------------------------------------------------------- 1 | @if (formError$ | async; as formError) { 2 |
3 | error 4 | {{ formError }} 5 |
6 | } @else { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-form/formly-form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .form-error { 6 | display: flex; 7 | align-items: center; 8 | column-gap: 4px; 9 | 10 | .mat-icon { 11 | flex-shrink: 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-form/formly-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FormlyFormComponent } from './formly-form.component'; 4 | 5 | describe('FormlyFormComponent', () => { 6 | let component: FormlyFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [FormlyFormComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(FormlyFormComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-form/formly-form.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, NgZone, OnChanges, SimpleChanges } from '@angular/core'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { FormlyConfig, FormlyForm, FormlyFormBuilder } from '@ngx-formly/core'; 5 | import { BehaviorSubject } from 'rxjs'; 6 | 7 | import { EditorFieldType, IEditorFormlyFieldConfigCache } from '../../editor.types'; 8 | import { RootFormlyFieldComponent } from '../formly-field/formly-field.component'; 9 | import { FormlyFieldTemplates } from '../formly.template'; 10 | 11 | @Component({ 12 | selector: 'editor-formly-form', 13 | templateUrl: './formly-form.component.html', 14 | styleUrls: ['./formly-form.component.scss'], 15 | providers: [FormlyFormBuilder, FormlyFieldTemplates], 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [MatIcon, RootFormlyFieldComponent, AsyncPipe], 18 | }) 19 | export class FormlyFormComponent extends FormlyForm implements OnChanges { 20 | public override field: IEditorFormlyFieldConfigCache = { 21 | _info: { 22 | name: undefined, 23 | formId: undefined, 24 | fieldId: undefined, 25 | fieldPath: undefined, 26 | }, 27 | type: EditorFieldType.FORMLY_GROUP, 28 | }; 29 | 30 | public formError$ = new BehaviorSubject(''); 31 | 32 | constructor( 33 | builder: FormlyFormBuilder, 34 | config: FormlyConfig, 35 | ngZone: NgZone, 36 | fieldTemplates: FormlyFieldTemplates 37 | ) { 38 | super(builder, config, ngZone, fieldTemplates); 39 | } 40 | 41 | override ngOnChanges(changes: SimpleChanges): void { 42 | try { 43 | super.ngOnChanges(changes); 44 | this.formError$.next(''); 45 | } catch (e) { 46 | this.field.fieldGroup = []; 47 | this.formError$.next(`Build Error: ${e.message}`); 48 | console.error(e); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-group/formly-group.component.html: -------------------------------------------------------------------------------- 1 | @if (field._info) { 2 |
6 | @for (f of field.fieldGroup; track f._info.fieldId; let index = $index; let first = $first; let last = $last) { 7 | 13 | 14 | } 15 |
16 | } @else { 17 | @for (f of field.fieldGroup; track f) { 18 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-group/formly-group.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | min-width: max-content; 4 | } 5 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-group/formly-group.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FormlyGroupComponent } from './formly-group.component'; 4 | 5 | describe('FormlyGroupComponent', () => { 6 | let component: FormlyGroupComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [FormlyGroupComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(FormlyGroupComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly-group/formly-group.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { FieldType, FormlyModule } from '@ngx-formly/core'; 3 | 4 | import { IEditorFormlyField } from '../../editor.types'; 5 | import { FormlyFieldComponent } from '../formly-field/formly-field.component'; 6 | 7 | @Component({ 8 | selector: 'editor-formly-group', 9 | templateUrl: './formly-group.component.html', 10 | styleUrls: ['./formly-group.component.scss'], 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | imports: [FormlyFieldComponent, FormlyModule], 13 | }) 14 | export class FormlyGroupComponent extends FieldType {} 15 | -------------------------------------------------------------------------------- /projects/editor/src/lib/custom-formly/formly.template.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Injectable, Input, OnChanges, QueryList, TemplateRef } from '@angular/core'; 2 | 3 | @Directive({ 4 | // eslint-disable-next-line @angular-eslint/directive-selector 5 | selector: '[formlyTemplate]', 6 | // eslint-disable-next-line @angular-eslint/prefer-standalone 7 | standalone: false, 8 | }) 9 | // eslint-disable-next-line @angular-eslint/directive-class-suffix 10 | export class FormlyTemplate implements OnChanges { 11 | @Input('formlyTemplate') name: string; 12 | 13 | constructor(public ref: TemplateRef) {} 14 | 15 | ngOnChanges() { 16 | this.name = this.name || 'formly-group'; 17 | } 18 | } 19 | 20 | // workarround for https://github.com/angular/angular/issues/43227#issuecomment-904173738 21 | @Injectable() 22 | export class FormlyFieldTemplates { 23 | templates!: QueryList; 24 | } 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/edit-field.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 11 | 12 |
13 |
14 | 15 | 20 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/edit-field.component.scss: -------------------------------------------------------------------------------- 1 | :host ::ng-deep { 2 | .mat-mdc-tab-body-wrapper { 3 | height: 100%; 4 | } 5 | 6 | .mat-expansion-panel { 7 | border: 1px solid var(--mat-sys-outline-variant); 8 | box-shadow: none !important; 9 | } 10 | } 11 | 12 | mat-tab-group { 13 | height: 100%; 14 | } 15 | 16 | editor-styles { 17 | height: 100%; 18 | } 19 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/edit-field.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { EditFieldComponent } from './edit-field.component'; 4 | 5 | describe('EditFieldComponent', () => { 6 | let component: EditFieldComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [EditFieldComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(EditFieldComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/edit-field.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | EventEmitter, 6 | Input, 7 | OnDestroy, 8 | OnInit, 9 | Output, 10 | ViewChild, 11 | } from '@angular/core'; 12 | import { MatTab, MatTabGroup } from '@angular/material/tabs'; 13 | import { Store } from '@ngrx/store'; 14 | import { Observable, Subject } from 'rxjs'; 15 | 16 | import { map, shareReplay, takeUntil, tap } from 'rxjs/operators'; 17 | import { EditorService } from '../editor.service'; 18 | import { IEditorFormlyField } from '../editor.types'; 19 | import { ObjectPropertyComponent } from '../property/object-property/object-property.component'; 20 | import { IObjectProperty } from '../property/object-property/object-property.types'; 21 | import { IPropertyChange, PropertyType } from '../property/property.types'; 22 | import { getDefaultProperty, initRootProperty } from '../property/property.utils'; 23 | import { StylesComponent } from './styles/styles.component'; 24 | 25 | @Component({ 26 | selector: 'editor-edit-field', 27 | templateUrl: './edit-field.component.html', 28 | styleUrls: ['./edit-field.component.scss'], 29 | changeDetection: ChangeDetectionStrategy.OnPush, 30 | imports: [MatTabGroup, MatTab, ObjectPropertyComponent, StylesComponent, AsyncPipe], 31 | }) 32 | export class EditFieldComponent implements OnInit, OnDestroy { 33 | @Input() resizeTabHeader$: Observable; 34 | 35 | @Output() fieldChanged = new EventEmitter(); 36 | 37 | @ViewChild(MatTabGroup) matTabGroup: MatTabGroup; 38 | 39 | public field$: Observable; 40 | public parentField$: Observable; 41 | public property$: Observable; 42 | 43 | private _destroy$ = new Subject(); 44 | private _cachedField: IEditorFormlyField; 45 | private _cachedProperty: IObjectProperty; 46 | 47 | constructor( 48 | private _editorService: EditorService, 49 | private _store: Store 50 | ) {} 51 | 52 | ngOnInit(): void { 53 | const activeField$ = this._store.select(this._editorService.feature.selectActiveField).pipe(shareReplay()); 54 | this.field$ = activeField$; 55 | this.parentField$ = activeField$.pipe(map(field => this._editorService.getField(field?._info.parentFieldId))); 56 | this.property$ = activeField$.pipe( 57 | tap(field => { 58 | if (!field) { 59 | this._cachedProperty = this._getProperty(null); 60 | } else if ( 61 | field._info.formId !== this._cachedField?._info.formId || 62 | field._info.fieldId !== this._cachedField?._info.fieldId || 63 | field.wrappers !== this._cachedField?.wrappers 64 | ) { 65 | this._cachedProperty = this._getProperty(field); 66 | } 67 | this._cachedField = field; 68 | }), 69 | map(() => this._cachedProperty) 70 | ); 71 | 72 | this.resizeTabHeader$?.pipe(takeUntil(this._destroy$)).subscribe(() => { 73 | this.matTabGroup?._tabHeader._alignInkBarToSelectedTab(); 74 | }); 75 | setTimeout(() => this.matTabGroup?._tabHeader._alignInkBarToSelectedTab(), 1000); 76 | } 77 | 78 | ngOnDestroy(): void { 79 | this._destroy$.next(); 80 | this._destroy$.complete(); 81 | } 82 | 83 | onFieldChanged(change: IPropertyChange): void { 84 | this.fieldChanged.emit(change); 85 | } 86 | 87 | private _getProperty(field: IEditorFormlyField | null): IObjectProperty { 88 | const property = getDefaultProperty(PropertyType.OBJECT) as IObjectProperty; 89 | const childProperties = field ? this._editorService.getFieldProperties(field) : []; 90 | initRootProperty(property, true, childProperties); 91 | return property; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/style-option/style-option.component.html: -------------------------------------------------------------------------------- 1 | @if (!breakpoint.value || option.hasBreakpoints) { 2 | 3 | {{ option.name }} 4 | 9 | @for (variant of option.variants; track variant) { 10 | 11 | {{ variant }} 12 | 13 | } 14 | 15 | 16 | } 17 | @if (selectedVariant) { 18 | 24 | } 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/style-option/style-option.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | align-items: center; 4 | column-gap: 8px; 5 | } 6 | 7 | .mat-mdc-form-field { 8 | width: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/style-option/style-option.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { StyleOptionComponent } from './style-option.component'; 4 | 5 | describe('StyleOptionComponent', () => { 6 | let component: StyleOptionComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [StyleOptionComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(StyleOptionComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/styles.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | person 5 | Style Self 6 | 7 | 11 | groups 12 | Style Content 13 | 14 | 15 |
16 | 20 | 21 | 28 | 29 | 30 | 31 | 38 | 39 | 40 | 41 | 42 | 47 | @if (categories) { 48 | 49 | @for (breakpoint of stylesConfig.breakpoints; track breakpoint.name) { 50 | 51 | 52 | {{ breakpoint.name }} 53 | 54 | 55 | @for (category of categories[breakpoint.value]; track category.name) { 56 | @if (category.options.length) { 57 | 58 | 59 | {{ category.name }} 60 | 61 | @for (option of category.options; track option.name) { 62 | 74 | } 75 | 76 | } 77 | } 78 | 79 | 85 | 86 | 87 | } 88 | 89 | } 90 | 91 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/styles.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | :host::ng-deep { 7 | .mat-mdc-tab-header { 8 | display: none; 9 | } 10 | 11 | .category-panel .mat-expansion-panel-body { 12 | display: grid; 13 | grid-template-columns: repeat(2, minmax(0, 1fr)); 14 | gap: 8px; 15 | 16 | .col-span-2 { 17 | grid-column-end: span 2; 18 | } 19 | } 20 | .mat-mdc-tab-body-content { 21 | padding: 8px; 22 | box-sizing: border-box; 23 | } 24 | } 25 | 26 | .toggle-group-wrapper { 27 | display: flex; 28 | flex-direction: column; 29 | flex-shrink: 0; 30 | padding: 8px; 31 | box-sizing: border-box; 32 | border-bottom: 1px solid var(--mat-sys-outline-variant); 33 | } 34 | 35 | .mat-mdc-tab-group { 36 | flex-grow: 1; 37 | overflow: auto; 38 | } 39 | 40 | editor-chip-list-property { 41 | margin-top: 8px; 42 | } 43 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/styles.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { StylesComponent } from './styles.component'; 4 | 5 | describe('StylesComponent', () => { 6 | let component: StylesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [StylesComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(StylesComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/styles.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core'; 2 | 3 | import { tailwindConfig } from './styles-config/tailwind.styles-config'; 4 | import { ClassProperty, IBreakpoint, IStyleOption, IStyleOptionCategory, IStylesConfig } from './styles.types'; 5 | import { formatVariant } from './styles.utils'; 6 | import { EDITOR_CONFIG, EditorConfig } from '../../editor.types'; 7 | 8 | @Injectable() 9 | export class StylesService { 10 | stylesConfig: IStylesConfig; 11 | classNames: string[]; 12 | fieldGroupClassNames: string[]; 13 | 14 | constructor(@Inject(EDITOR_CONFIG) editorConfig: EditorConfig) { 15 | this.stylesConfig = editorConfig.stylesConfig ?? tailwindConfig; 16 | this.classNames = this._getPropertyClasses(ClassProperty.CLASS_NAME); 17 | this.fieldGroupClassNames = this._getPropertyClasses(ClassProperty.FIELD_GROUP_CLASS_NAME); 18 | } 19 | 20 | getClasses(categories: IStyleOptionCategory[], breakpoint: IBreakpoint): string[] { 21 | return categories.reduce( 22 | (a, category) => [ 23 | ...a, 24 | ...category.options.reduce((b, option) => [...b, ...this._getOptionClasses(option, breakpoint)], []), 25 | ], 26 | [] 27 | ); 28 | } 29 | 30 | private _getOptionClasses(option: IStyleOption, breakpoint: IBreakpoint): string[] { 31 | const classes = 32 | !breakpoint.value || option.hasBreakpoints 33 | ? option.variants.map(variant => 34 | formatVariant(variant, option, breakpoint, this.stylesConfig.breakpointAffix) 35 | ) 36 | : []; 37 | 38 | return classes; 39 | } 40 | 41 | private _getPropertyClasses(classProperty: ClassProperty): string[] { 42 | return this.stylesConfig.breakpoints.reduce( 43 | (a, breakpoint) => [...a, ...this.getClasses(this.stylesConfig[classProperty], breakpoint)], 44 | [] 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/styles.types.ts: -------------------------------------------------------------------------------- 1 | export enum ClassProperty { 2 | CLASS_NAME = 'className', 3 | FIELD_GROUP_CLASS_NAME = 'fieldGroupClassName', 4 | } 5 | 6 | export enum BreakpointAffix { 7 | PREFIX = 'prefix', 8 | INFIX = 'infix', 9 | SUFFIX = 'suffix', 10 | } 11 | 12 | interface IStyleDependency { 13 | property: ClassProperty; 14 | value: string; 15 | } 16 | 17 | export interface IStyleOption { 18 | name: string; 19 | value?: string; 20 | variants: string[]; 21 | hasBreakpoints?: boolean; 22 | spanWidth?: boolean; 23 | dependsOn?: IStyleDependency; 24 | dependsOnParent?: IStyleDependency; 25 | } 26 | 27 | export interface IStylesConfig { 28 | breakpointAffix: BreakpointAffix; 29 | breakpoints: IBreakpoint[]; 30 | className: IStyleOptionCategory[]; 31 | fieldGroupClassName: IStyleOptionCategory[]; 32 | } 33 | 34 | export interface IStyleOptionCategory { 35 | name: string; 36 | options: IStyleOption[]; 37 | } 38 | 39 | export interface IBreakpoint { 40 | name: string; 41 | description: string; 42 | value: string; 43 | } 44 | -------------------------------------------------------------------------------- /projects/editor/src/lib/edit-field/styles/styles.utils.ts: -------------------------------------------------------------------------------- 1 | import { BreakpointAffix, IBreakpoint, IStyleOption } from './styles.types'; 2 | 3 | export const formatVariant = (variant: string, option: IStyleOption, bp: IBreakpoint, bpAffix: BreakpointAffix) => { 4 | const optionValue = `${option.value ? `${option.value}-` : ''}`; 5 | switch (bpAffix) { 6 | case BreakpointAffix.PREFIX: 7 | return `${bp.value}${optionValue}${variant}`; 8 | case BreakpointAffix.INFIX: 9 | return `${optionValue}${bp.value ? `${bp.value}-` : ''}${variant}`; 10 | case BreakpointAffix.SUFFIX: 11 | return `${optionValue}${variant}${bp.value}`; 12 | } 13 | }; 14 | 15 | export const findVariant = (source: string, option: IStyleOption, bp: IBreakpoint, bpAffix: BreakpointAffix) => { 16 | const variantsStr = `(${option.variants.join('|')})`; 17 | const optionValue = `${option.value ? `${option.value}-` : ''}`; 18 | let regexStr: string; 19 | switch (bpAffix) { 20 | case BreakpointAffix.PREFIX: 21 | regexStr = `(?<=${bp.value || '(\\s|^)'}${optionValue})${variantsStr}(?=(\\s|$))`; 22 | break; 23 | case BreakpointAffix.INFIX: 24 | regexStr = `(?<=(\\s|^)${optionValue}${bp.value ? `${bp.value}-` : ''})${variantsStr}(?=(\\s|$))`; 25 | break; 26 | case BreakpointAffix.SUFFIX: 27 | regexStr = `(?<=(\\s|^)${optionValue})${variantsStr}(?=${bp.value || '(\\s|$)'})`; 28 | break; 29 | } 30 | 31 | const matches: string[] | null = source.match(new RegExp(regexStr)); 32 | return matches?.[0]; 33 | }; 34 | -------------------------------------------------------------------------------- /projects/editor/src/lib/editor.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | :host { 4 | --editor-header-height: 44px; 5 | --editor-tree-item-height: 32px; 6 | 7 | --add-form-btn-width: 120px; 8 | --add-form-width: calc(var(--add-form-btn-width) + 16px); 9 | 10 | display: flex; 11 | height: 100%; 12 | 13 | ::ng-deep { 14 | .form-tab-group > { 15 | & .mat-mdc-tab-header { 16 | @include mat.icon-button-density(-4); 17 | .mat-mdc-icon-button { 18 | line-height: normal; 19 | } 20 | 21 | margin-right: var(--add-form-width); 22 | 23 | .inner-tab-label-content { 24 | display: inline-flex; 25 | align-items: center; 26 | column-gap: 16px; 27 | } 28 | } 29 | 30 | & .mat-mdc-tab-body-wrapper { 31 | height: 100%; 32 | } 33 | } 34 | } 35 | } 36 | 37 | editor-sidebar { 38 | min-width: 200px; 39 | max-width: 40%; 40 | 41 | &.left { 42 | width: 20%; 43 | } 44 | 45 | &.right { 46 | width: 25%; 47 | } 48 | 49 | &.left.hidden, 50 | &.right.hidden { 51 | width: 0 !important; 52 | min-width: 0; 53 | overflow: hidden; 54 | } 55 | 56 | transition: 0.15s ease-out; 57 | } 58 | 59 | .section-header-content { 60 | flex-grow: 1; 61 | display: flex; 62 | justify-content: space-between; 63 | align-items: center; 64 | } 65 | 66 | .form-view { 67 | flex-grow: 1; 68 | height: 100%; 69 | position: relative; 70 | overflow-x: auto; 71 | } 72 | 73 | .form-tab-group { 74 | width: 100%; 75 | } 76 | 77 | .mat-mdc-tab-group { 78 | height: 100%; 79 | } 80 | 81 | .add-form-btn-wrapper { 82 | position: absolute; 83 | top: 0; 84 | right: 0; 85 | display: flex; 86 | align-items: center; 87 | justify-content: center; 88 | column-gap: 8px; 89 | height: calc(var(--editor-header-height) + 1px); 90 | width: var(--add-form-width); 91 | border-bottom: 1px solid var(--mat-sys-outline-variant); 92 | box-sizing: border-box; 93 | } 94 | 95 | .add-form-btn { 96 | width: var(--add-form-btn-width); 97 | } 98 | 99 | .empty-view { 100 | display: flex; 101 | flex-direction: column; 102 | justify-content: center; 103 | align-items: center; 104 | height: 100%; 105 | row-gap: 16px; 106 | } 107 | 108 | .empty-view-btn { 109 | width: 220px; 110 | } 111 | 112 | .loading-view { 113 | display: flex; 114 | flex-direction: column; 115 | justify-content: center; 116 | align-items: center; 117 | height: 100%; 118 | width: 100%; 119 | row-gap: 16px; 120 | } 121 | 122 | .lds-dual-ring { 123 | display: inline-block; 124 | width: 120px; 125 | height: 120px; 126 | } 127 | 128 | .lds-dual-ring:after { 129 | content: ' '; 130 | display: block; 131 | width: 100px; 132 | height: 100px; 133 | border-radius: 50%; 134 | border: 10px solid; 135 | border-color: var(--mat-sys-primary) transparent var(--mat-sys-outline) transparent; 136 | animation: lds-dual-ring 1.2s linear infinite; 137 | } 138 | 139 | @keyframes lds-dual-ring { 140 | 0% { 141 | transform: rotate(0deg); 142 | } 143 | 100% { 144 | transform: rotate(360deg); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /projects/editor/src/lib/editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { EditorComponent } from './editor.component'; 4 | 5 | describe('EditorComponent', () => { 6 | let component: EditorComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [EditorComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(EditorComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/editor.module.ts: -------------------------------------------------------------------------------- 1 | import { ModuleWithProviders, NgModule } from '@angular/core'; 2 | 3 | import { EditorComponent } from './editor.component'; 4 | import { provideEditor, provideEditorConfig, withConfig } from './editor.provider'; 5 | import { EditorConfig } from './editor.types'; 6 | 7 | @NgModule({ 8 | imports: [EditorComponent], 9 | exports: [EditorComponent], 10 | }) 11 | export class EditorModule { 12 | static forRoot(config?: EditorConfig): ModuleWithProviders { 13 | return { 14 | ngModule: EditorModule, 15 | providers: [provideEditor(config ? withConfig(config) : undefined)], 16 | }; 17 | } 18 | static forChild(config: EditorConfig): ModuleWithProviders { 19 | return { 20 | ngModule: EditorModule, 21 | providers: [provideEditorConfig(config)], 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/editor.provider.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; 2 | import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog'; 3 | import { MAT_TABS_CONFIG } from '@angular/material/tabs'; 4 | import { provideDnd } from '@ng-dnd/core'; 5 | import { HTML5Backend } from '@ng-dnd/multi-backend'; 6 | import { ActionReducer, META_REDUCERS, provideState, provideStore } from '@ngrx/store'; 7 | import { ConfigOption, FORMLY_CONFIG } from '@ngx-formly/core'; 8 | 9 | import { FormlyGroupComponent } from './custom-formly/formly-group/formly-group.component'; 10 | import { StylesService } from './edit-field/styles/styles.service'; 11 | import { EditorService } from './editor.service'; 12 | import { EDITOR_CONFIG, EditorConfig, EditorFieldType, FieldTypeOption } from './editor.types'; 13 | import { FieldService } from './field-service/field.service'; 14 | import { createEditorFeature } from './state/state.reducers'; 15 | import { EDITOR_FEATURE, EditorFeature } from './state/state.types'; 16 | 17 | const metaReducerFactory = 18 | () => 19 | (reducer: ActionReducer): ActionReducer => 20 | (state, action) => { 21 | console.log('Action:', action.type); 22 | return reducer(state, action); 23 | }; 24 | 25 | const defaultConfig: EditorConfig = { 26 | id: 'editor', 27 | fieldOptions: [], 28 | }; 29 | 30 | const defaultGenericTypeOption: FieldTypeOption = { 31 | displayName: 'Generic', 32 | name: EditorFieldType.GENERIC, 33 | disableKeyGeneration: true, 34 | defaultConfig: {}, 35 | }; 36 | 37 | export function provideEditor(configProviders?: EnvironmentProviders): EnvironmentProviders { 38 | return makeEnvironmentProviders([ 39 | ...(configProviders ? [configProviders] : []), 40 | { 41 | provide: MAT_DIALOG_DEFAULT_OPTIONS, 42 | useValue: { 43 | height: '80%', 44 | width: '80%', 45 | maxHeight: '675px', 46 | maxWidth: '1200px', 47 | hasBackdrop: true, 48 | }, 49 | }, 50 | { 51 | provide: MAT_TABS_CONFIG, 52 | useValue: { 53 | animationDuration: '250ms', 54 | }, 55 | }, 56 | { 57 | provide: META_REDUCERS, 58 | useFactory: metaReducerFactory, 59 | multi: true, 60 | }, 61 | provideStore(), 62 | provideDnd({ backend: HTML5Backend }), 63 | ]); 64 | } 65 | 66 | export function provideEditorConfig(config: EditorConfig = defaultConfig): EnvironmentProviders { 67 | return withConfig(config); 68 | } 69 | 70 | export function withConfig(config: EditorConfig): EnvironmentProviders { 71 | config.genericTypeOption = config.genericTypeOption ?? defaultGenericTypeOption; 72 | const feature: EditorFeature = createEditorFeature(config.id); 73 | const formlyConfig: ConfigOption = { types: [{ name: 'formly-group', component: FormlyGroupComponent }] }; 74 | 75 | return makeEnvironmentProviders([ 76 | { 77 | provide: EDITOR_CONFIG, 78 | useValue: config, 79 | }, 80 | { 81 | provide: EDITOR_FEATURE, 82 | useValue: feature, 83 | }, 84 | { 85 | provide: FORMLY_CONFIG, 86 | useValue: formlyConfig, 87 | multi: true, 88 | }, 89 | provideState(feature), 90 | EditorService, 91 | FieldService, 92 | StylesService, 93 | ]); 94 | } 95 | -------------------------------------------------------------------------------- /projects/editor/src/lib/editor.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { EditorService } from './editor.service'; 4 | 5 | describe('EditorService', () => { 6 | let service: EditorService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(EditorService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /projects/editor/src/lib/editor.types.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { FormlyFieldConfig } from '@ngx-formly/core'; 3 | 4 | import { FormlyFieldConfigCache } from './custom-formly/fieldconfig.cache'; 5 | import { IStylesConfig } from './edit-field/styles/styles.types'; 6 | import { IProperty } from './property/property.types'; 7 | 8 | export enum EditorFieldType { 9 | FORMLY_GROUP = 'formly-group', 10 | GENERIC = 'generic', 11 | } 12 | 13 | export enum DropAction { 14 | COPY = 'copy', 15 | MOVE = 'move', 16 | } 17 | 18 | export enum DragType { 19 | FORMLY_FIELD = 'formly-field', 20 | } 21 | 22 | export type GetDefaultField = (type: string) => FormlyFieldConfig; 23 | export type GetTypeOption = (type: string) => FieldTypeOption; 24 | 25 | export interface IEditorFieldInfo { 26 | name: string; 27 | formId: string; 28 | fieldId: string; 29 | parentFieldId?: string; 30 | fieldPath: string[]; 31 | childrenConfig?: FieldTypeChildrenConfig; 32 | } 33 | 34 | export interface IEditorFormlyField extends FormlyFieldConfig { 35 | _info: IEditorFieldInfo; 36 | fieldGroup?: IEditorFormlyField[]; 37 | } 38 | 39 | export interface IForm { 40 | id: string; 41 | name: string; 42 | fields: IEditorFormlyField[]; 43 | baseFields: IEditorFormlyField[]; 44 | model: object; 45 | activeFieldId?: string; 46 | isEditMode: boolean; 47 | } 48 | 49 | export interface IDefaultForm { 50 | name: string; 51 | fields: FormlyFieldConfig[]; 52 | model: object; 53 | } 54 | 55 | export type IEditorFormlyFieldConfigCache = IEditorFormlyField & FormlyFieldConfigCache; 56 | 57 | export type FieldOption = FieldCategoryOption | FieldTypeOption; 58 | 59 | export interface FieldCategoryOption { 60 | displayName: string; 61 | children: FieldTypeOption[]; 62 | } 63 | 64 | export interface FieldTypeOption { 65 | name: string; 66 | displayName: string; 67 | keyGenerationPrefix?: string; 68 | disableKeyGeneration?: boolean; // Prevent auto generating keys 69 | childrenConfig?: FieldTypeChildrenConfig; 70 | defaultConfig: FormlyFieldConfig; 71 | properties?: IProperty[]; 72 | } 73 | 74 | export interface FieldTypeChildrenConfig { 75 | path: string; // Dot separated lodash path for children 76 | isObject?: boolean; // Whether child is a single object instead of a list of children 77 | } 78 | 79 | export interface FieldWrapperOption { 80 | name: string; 81 | properties?: IProperty[]; 82 | } 83 | 84 | export interface ValidatorOption { 85 | name: string; 86 | key: string; 87 | } 88 | 89 | export interface EditorConfig { 90 | id: string; 91 | fieldOptions: FieldOption[]; 92 | wrapperOptions?: FieldWrapperOption[]; 93 | genericTypeOption?: FieldTypeOption; 94 | validatorOptions?: ValidatorOption[]; 95 | asyncValidatorOptions?: ValidatorOption[]; 96 | defaultForm?: IDefaultForm; 97 | stylesConfig?: IStylesConfig; 98 | autoSaveDelay?: number; 99 | onDisplayFields?: (fields: IEditorFormlyField[], model: Record) => IEditorFormlyField[]; 100 | } 101 | 102 | export const EDITOR_CONFIG = new InjectionToken('EDITOR_CONFIG'); 103 | -------------------------------------------------------------------------------- /projects/editor/src/lib/editor.utils.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl } from '@angular/forms'; 2 | import { FieldCategoryOption, FieldOption, FieldTypeOption } from './editor.types'; 3 | 4 | export const isCategoryOption = (x: FieldOption): x is FieldCategoryOption => !!(x as FieldCategoryOption).children; 5 | export const isTypeOption = (x: FieldOption): x is FieldTypeOption => !!(x as FieldTypeOption).name; 6 | 7 | export const getKeyPath = (control: AbstractControl): string => { 8 | const path: string[] = []; 9 | while (control?.parent) { 10 | const siblings: Record | AbstractControl[] = control.parent.controls; 11 | const key: string | number = Object.keys(siblings).find(k => siblings[k] === control); 12 | if (key) { 13 | path.unshift(key); 14 | } 15 | control = control.parent; 16 | } 17 | 18 | return path.join('.'); 19 | }; 20 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-drag-drop/field-drag-drop.types.ts: -------------------------------------------------------------------------------- 1 | import { DropAction, IEditorFormlyField } from '../editor.types'; 2 | 3 | export type FieldDropPosition = 'left' | 'center' | 'right'; 4 | 5 | export interface FieldDropWidth { 6 | left: number; 7 | center?: number; 8 | right: number; 9 | } 10 | 11 | export interface IFieldDragData { 12 | action: DropAction; 13 | index: number; 14 | field: IEditorFormlyField; 15 | fieldParent?: IEditorFormlyField; 16 | } 17 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-drag-drop/field-drop-overlay/field-drop-overlay.component.html: -------------------------------------------------------------------------------- 1 |
6 | @if (dropWidth.center) { 7 |
12 | } 13 |
18 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-drag-drop/field-drop-overlay/field-drop-overlay.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | position: absolute; 3 | inset: 0; 4 | display: flex; 5 | pointer-events: none; 6 | touch-action: none; 7 | background-color: rgb(0, 0, 0, 0.05); 8 | border-radius: 4px; 9 | opacity: 0; 10 | transition: opacity 0.15s ease-out; 11 | 12 | &.hovering { 13 | opacity: 1; 14 | } 15 | 16 | .dropzone { 17 | background-color: greenyellow; 18 | opacity: 0; 19 | transition: opacity 0.15s ease-out; 20 | 21 | &.hovering { 22 | opacity: 0.5; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-drag-drop/field-drop-overlay/field-drop-overlay.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FieldDropOverlayComponent } from './field-drop-overlay.component'; 4 | 5 | describe('FieldDropOverlayComponent', () => { 6 | let component: FieldDropOverlayComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [FieldDropOverlayComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(FieldDropOverlayComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-drag-drop/field-drop-overlay/field-drop-overlay.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, HostBinding, Input } from '@angular/core'; 2 | import { FieldDropPosition, FieldDropWidth } from '../field-drag-drop.types'; 3 | 4 | @Component({ 5 | selector: 'editor-field-drop-overlay', 6 | templateUrl: './field-drop-overlay.component.html', 7 | styleUrls: ['./field-drop-overlay.component.scss'], 8 | imports: [], 9 | }) 10 | export class FieldDropOverlayComponent { 11 | @Input() @HostBinding('class.hovering') isHovering: boolean; 12 | @Input() hoverPosition: FieldDropPosition; 13 | @Input() dropWidth: FieldDropWidth; 14 | } 15 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-name/field-name.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { FieldNamePipe } from './field-name.pipe'; 2 | 3 | describe('FieldNamePipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new FieldNamePipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-name/field-name.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'fieldName', 5 | standalone: true, 6 | }) 7 | export class FieldNamePipe implements PipeTransform { 8 | transform(name: string, key?: string | number | (string | number)[]): string { 9 | return `${name}${key ? ` (${key})` : ''}`; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-service/field.service.utils.ts: -------------------------------------------------------------------------------- 1 | import { FieldOption, FieldTypeOption } from '../editor.types'; 2 | import { isCategoryOption } from '../editor.utils'; 3 | import { IProperty } from '../property/property.types'; 4 | 5 | export const getTypeOptions = (options: FieldOption[]) => 6 | options.reduce( 7 | (a, b): FieldTypeOption[] => [...a, ...(isCategoryOption(b) ? getTypeOptions(b.children) : [b])], 8 | [] 9 | ); 10 | 11 | export const getPropertiesMap = (options: { name: string; properties?: IProperty[] }[]) => 12 | options.reduce( 13 | (acc, option) => ({ 14 | ...acc, 15 | [option.name]: option.properties ?? [], 16 | }), 17 | {} 18 | ); 19 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-tree-item/field-tree-item.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | position: relative; 3 | display: block; 4 | } 5 | 6 | .header { 7 | display: inline-flex; 8 | align-items: center; 9 | column-gap: 4px; 10 | } 11 | -------------------------------------------------------------------------------- /projects/editor/src/lib/field-tree-item/field-tree-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FieldTreeItemComponent } from './field-tree-item.component'; 4 | 5 | describe('FieldTreeItemComponent', () => { 6 | let component: FieldTreeItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [FieldTreeItemComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(FieldTreeItemComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/file/file.utils.ts: -------------------------------------------------------------------------------- 1 | import { saveAs } from 'file-saver-es'; 2 | import { Observable } from 'rxjs'; 3 | 4 | export function saveFile(fileName: string, content: string): void { 5 | const blob: Blob = new Blob([content], { type: 'application/json' }); 6 | saveAs(blob, fileName); 7 | } 8 | 9 | export function readFile(file: File): Observable { 10 | return new Observable(sub => { 11 | const reader: FileReader = new FileReader(); 12 | reader.onload = readerEvent => { 13 | const content: string = readerEvent.target.result as string; 14 | sub.next(content); 15 | sub.complete(); 16 | }; 17 | 18 | reader.readAsText(file); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/form.component.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | @if (formFields$ | async; as fields) { 13 | @if (fields.length) { 14 | 18 | 19 | 26 | 27 | 28 | 29 | 30 | 36 | 37 | 38 | 39 | } @else { 40 |
41 | 50 |
51 | } 52 | } 53 | 54 | 58 | 62 | @for (option of options; track option.displayName) { 63 | @if (isCategoryOption(option)) { 64 | 72 | } 73 | @if (isTypeOption(option)) { 74 | 78 | 79 | } 80 | } 81 | 82 | 83 | 84 | 88 | 92 | @for (child of children; track child.displayName) { 93 | @if (isTypeOption(child)) { 94 | 98 | 99 | } 100 | } 101 | 102 | 103 | 104 | 109 | 115 | 116 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | height: 100%; 4 | 5 | ::ng-deep { 6 | .tab-group { 7 | & > .mat-mdc-tab-header { 8 | display: none; 9 | } 10 | & > .mat-mdc-tab-body-wrapper > .mat-mdc-tab-body > .mat-mdc-tab-body-content { 11 | padding: 8px; 12 | box-sizing: border-box; 13 | } 14 | } 15 | } 16 | } 17 | 18 | .tab-group { 19 | height: calc(100% - var(--editor-header-height) - 1px); 20 | } 21 | 22 | .categories-wrapper { 23 | min-width: max-content; 24 | padding: 8px; 25 | box-sizing: border-box; 26 | } 27 | 28 | .section-header-content { 29 | flex-grow: 1; 30 | display: flex; 31 | justify-content: space-between; 32 | align-items: center; 33 | } 34 | 35 | editor-sidebar { 36 | min-width: 200px; 37 | max-width: 40%; 38 | 39 | &.left { 40 | width: 20%; 41 | } 42 | 43 | &.right { 44 | width: 30%; 45 | } 46 | } 47 | 48 | .no-fields-wrapper { 49 | display: flex; 50 | justify-content: center; 51 | margin-top: 8px; 52 | } 53 | 54 | .text-editor { 55 | height: 100%; 56 | } 57 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FormComponent } from './form.component'; 4 | 5 | describe('FormComponent', () => { 6 | let component: FormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [FormComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(FormComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/form.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { FormService } from './form.service'; 4 | 5 | describe('FormService', () => { 6 | let service: FormService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(FormService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/form.utils.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { get, isEmpty, set, unset } from 'lodash-es'; 3 | 4 | import { IEditorFormlyField } from '../editor.types'; 5 | 6 | export const getFieldChildren = (field: T): T | T[] | undefined => 7 | get(field, field._info.childrenConfig.path); 8 | 9 | export const setFieldChildren = (field: T, children: T | T[]): T => 10 | produce(field, draft => { 11 | set(draft, field._info.childrenConfig.path, children); 12 | }); 13 | 14 | export const cleanField = (field: IEditorFormlyField, cleanChildren = true, removeEditorProperties?: boolean): void => { 15 | if (cleanChildren && field._info.childrenConfig) { 16 | const children: IEditorFormlyField | IEditorFormlyField[] = getFieldChildren(field); 17 | if (Array.isArray(children)) { 18 | children.forEach(child => { 19 | cleanField(child, cleanChildren, removeEditorProperties); 20 | }); 21 | } else if (children) { 22 | cleanField(children, cleanChildren, removeEditorProperties); 23 | } 24 | } 25 | 26 | if (removeEditorProperties) { 27 | _removeEditorProperties(field); 28 | _removeEmptyProperties(field); 29 | } 30 | }; 31 | 32 | const _removeEmptyProperties = (field: IEditorFormlyField): void => { 33 | ['wrappers', 'templateOptions', 'props', 'expressionProperties', 'className', 'fieldGroupClassName'].forEach(p => { 34 | if (isEmpty(get(field, p))) { 35 | unset(field, p); 36 | } 37 | }); 38 | }; 39 | 40 | const _removeEditorProperties = (field: IEditorFormlyField): void => { 41 | delete field._info; 42 | }; 43 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/toolbar/toolbar.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | dashboard 7 | Form 8 | 9 | 10 | code 11 | JSON 12 | 13 | 14 | 15 |
16 | Edit Mode 22 | 29 | 36 | 43 | 50 |
51 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/toolbar/toolbar.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | height: calc(var(--editor-header-height) + 1px); 6 | padding: 0 8px; 7 | border-bottom: 1px solid var(--mat-sys-outline-variant); 8 | box-sizing: border-box; 9 | } 10 | 11 | .toggle-sidebars-icon { 12 | transform: rotate(90deg); 13 | } 14 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/toolbar/toolbar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ToolbarComponent } from './toolbar.component'; 4 | 5 | describe('ToolbarComponent', () => { 6 | let component: ToolbarComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ToolbarComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ToolbarComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/form/toolbar/toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; 2 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 3 | import { MatIconButton } from '@angular/material/button'; 4 | import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; 5 | import { MatIcon } from '@angular/material/icon'; 6 | import { MatSlideToggle } from '@angular/material/slide-toggle'; 7 | 8 | import { EditorService } from '../../editor.service'; 9 | 10 | @Component({ 11 | selector: 'editor-toolbar', 12 | templateUrl: './toolbar.component.html', 13 | styleUrls: ['./toolbar.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | imports: [ 16 | MatButtonToggleGroup, 17 | MatButtonToggle, 18 | MatIcon, 19 | MatSlideToggle, 20 | ReactiveFormsModule, 21 | FormsModule, 22 | MatIconButton, 23 | ], 24 | }) 25 | export class ToolbarComponent { 26 | @Input() tabIndex: 0 | 1; 27 | @Input() isEditMode: boolean; 28 | 29 | @Output() tabIndexChange = new EventEmitter<0 | 1>(); 30 | @Output() isEditModeChange = new EventEmitter(); 31 | @Output() resetModel = new EventEmitter(); 32 | @Output() duplicateForm = new EventEmitter(); 33 | @Output() exportForm = new EventEmitter(); 34 | @Output() toggleSidebars = new EventEmitter(); 35 | 36 | isExpanded = true; 37 | 38 | constructor(public editorService: EditorService) {} 39 | } 40 | -------------------------------------------------------------------------------- /projects/editor/src/lib/json-dialog/json-dialog.component.html: -------------------------------------------------------------------------------- 1 |

{{ data.title }}

2 | 3 |
4 | @if (data.name) { 5 | 6 | Name 7 | 16 | @if (nameModel.errors?.required) { 17 | This field is required 18 | } 19 | @if (nameModel.errors?.pattern) { 20 | Invalid pattern 21 | } 22 | 23 | } 24 |
25 | 26 | JSON 27 | 37 | @if (jsonModel.errors?.required) { 38 | This field is required 39 | } 40 | @if (jsonModel.errors?.jsonFormat) { 41 | Invalid JSON 42 | } 43 | 44 | @if (data.canSelectFile) { 45 | 54 | 62 | } 63 |
64 |
65 |
66 | 67 | 74 | 83 | 84 | -------------------------------------------------------------------------------- /projects/editor/src/lib/json-dialog/json-dialog.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | } 6 | 7 | .mat-mdc-dialog-content { 8 | flex-grow: 1; 9 | } 10 | 11 | .mat-mdc-dialog-title { 12 | margin: 0; 13 | } 14 | 15 | .json-field-wrapper { 16 | display: flex; 17 | column-gap: 8px; 18 | flex-grow: 1; 19 | overflow: auto; 20 | } 21 | 22 | .json-field { 23 | flex-grow: 1; 24 | } 25 | 26 | .file-select-button { 27 | align-self: flex-start; 28 | } 29 | 30 | .text-editor { 31 | height: 100%; 32 | } 33 | 34 | form { 35 | display: flex; 36 | flex-direction: column; 37 | row-gap: 8px; 38 | height: 100%; 39 | } 40 | 41 | :host ::ng-deep { 42 | .mat-mdc-tab-body-content { 43 | display: flex; 44 | flex-direction: column; 45 | padding: 8px; 46 | box-sizing: border-box; 47 | } 48 | 49 | .json-field { 50 | .mat-mdc-form-field-flex, 51 | .mat-mdc-form-field-infix { 52 | height: 100%; 53 | } 54 | .mat-mdc-form-field-infix { 55 | padding-bottom: 20px; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /projects/editor/src/lib/json-dialog/json-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { JSONDialogComponent } from './json-dialog.component'; 4 | 5 | describe('ImportFormDialogComponent', () => { 6 | let component: JSONDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [JSONDialogComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(JSONDialogComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/json-dialog/json-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { CdkScrollable } from '@angular/cdk/scrolling'; 2 | 3 | import { AfterViewInit, Component, ElementRef, Inject, ViewChild } from '@angular/core'; 4 | import { FormsModule, NgForm, ReactiveFormsModule } from '@angular/forms'; 5 | import { MatButton } from '@angular/material/button'; 6 | import { 7 | MAT_DIALOG_DATA, 8 | MatDialogActions, 9 | MatDialogClose, 10 | MatDialogContent, 11 | MatDialogRef, 12 | MatDialogTitle, 13 | } from '@angular/material/dialog'; 14 | import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; 15 | import { MatIcon } from '@angular/material/icon'; 16 | import { MatInput } from '@angular/material/input'; 17 | 18 | import { readFile } from '../file/file.utils'; 19 | import { TextEditorComponent } from '../text-editor/text-editor.component'; 20 | import { ImportJSONData, ImportJSONValue } from './json-dialog.types'; 21 | import { JSONValidatorDirective } from './json-validator/json-validator.directive'; 22 | 23 | @Component({ 24 | templateUrl: './json-dialog.component.html', 25 | styleUrls: ['./json-dialog.component.scss'], 26 | imports: [ 27 | MatDialogTitle, 28 | CdkScrollable, 29 | MatDialogContent, 30 | ReactiveFormsModule, 31 | FormsModule, 32 | MatFormField, 33 | MatLabel, 34 | MatInput, 35 | MatError, 36 | TextEditorComponent, 37 | JSONValidatorDirective, 38 | MatButton, 39 | MatIcon, 40 | MatDialogActions, 41 | MatDialogClose, 42 | ], 43 | }) 44 | export class JSONDialogComponent implements AfterViewInit { 45 | @ViewChild('form', { read: NgForm }) 46 | form: NgForm; 47 | 48 | @ViewChild('fileSelect', { read: ElementRef }) 49 | fileSelectElement: ElementRef; 50 | 51 | constructor( 52 | @Inject(MAT_DIALOG_DATA) public data: ImportJSONData, 53 | private _dialogRef: MatDialogRef 54 | ) {} 55 | 56 | ngAfterViewInit(): void { 57 | setTimeout(() => { 58 | this.form.controls.json.setValue(this.data.defaultValue?.json); 59 | if (this.data.name) { 60 | this.form.controls.name.setValue(this.data.defaultValue?.name); 61 | } 62 | }); 63 | } 64 | 65 | onFileChanged(): void { 66 | const file: File = this.fileSelectElement.nativeElement.files.item(0); 67 | readFile(file).subscribe(text => { 68 | const jsonControl = this.form.controls.json; 69 | jsonControl.setValue(text); 70 | jsonControl.markAsTouched(); 71 | }); 72 | } 73 | 74 | onImport(): void { 75 | this._dialogRef.close(this.form.value); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /projects/editor/src/lib/json-dialog/json-dialog.types.ts: -------------------------------------------------------------------------------- 1 | export interface ImportJSONData { 2 | title: string; 3 | primaryActionText: string; 4 | defaultValue?: ImportJSONValue; 5 | name?: { 6 | pattern?: RegExp; 7 | placeholder?: string; 8 | }; 9 | canSelectFile?: boolean; 10 | } 11 | export interface ImportJSONValue { 12 | name?: string; 13 | json: string; 14 | } 15 | -------------------------------------------------------------------------------- /projects/editor/src/lib/json-dialog/json-validator/json-validator.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { JSONValidatorDirective } from './json-validator.directive'; 2 | 3 | describe('JsonValidatorDirective', () => { 4 | it('should create an instance', () => { 5 | const directive = new JSONValidatorDirective(); 6 | expect(directive).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /projects/editor/src/lib/json-dialog/json-validator/json-validator.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from '@angular/core'; 2 | import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; 3 | 4 | @Directive({ 5 | selector: '[editorJsonValidator]', 6 | providers: [ 7 | { 8 | provide: NG_VALIDATORS, 9 | useExisting: JSONValidatorDirective, 10 | multi: true, 11 | }, 12 | ], 13 | standalone: true, 14 | }) 15 | export class JSONValidatorDirective implements Validator { 16 | validate(control: AbstractControl): ValidationErrors | null { 17 | if (control.value) { 18 | try { 19 | JSON.parse(control.value); 20 | return {}; 21 | } catch { 22 | return { jsonFormat: true }; 23 | } 24 | } 25 | 26 | return {}; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/array-property/array-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .simplified-panel { 6 | flex-grow: 1; 7 | } 8 | 9 | .simplified-header { 10 | display: inline-flex; 11 | justify-content: space-between; 12 | align-items: center; 13 | width: 100%; 14 | margin-right: 16px; 15 | padding: 8px 0; 16 | cursor: pointer; 17 | } 18 | 19 | .simplified-header-left { 20 | flex-grow: 1; 21 | display: inline-flex; 22 | column-gap: 4px; 23 | } 24 | 25 | .line { 26 | width: 1px; 27 | background-color: var(--mat-sys-outline-variant); 28 | margin: 0 14px; 29 | } 30 | 31 | .mat-accordion { 32 | flex-grow: 1; 33 | display: block; 34 | } 35 | 36 | .simple-remove-btn { 37 | margin-right: 16px; 38 | } 39 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/array-property/array-property.component.ts: -------------------------------------------------------------------------------- 1 | import { NgTemplateOutlet } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; 3 | import { MatIconButton } from '@angular/material/button'; 4 | import { 5 | MatAccordion, 6 | MatExpansionPanel, 7 | MatExpansionPanelHeader, 8 | MatExpansionPanelTitle, 9 | } from '@angular/material/expansion'; 10 | import { MatIcon } from '@angular/material/icon'; 11 | import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; 12 | 13 | import { TreeItemComponent } from '../../tree-item/tree-item.component'; 14 | import { BooleanPropertyComponent } from '../boolean-property/boolean-property.component'; 15 | import { ChipListPropertyComponent } from '../chip-list-property/chip-list-property.component'; 16 | import { ObjectArrayPropertyDirective } from '../collection.property.directive'; 17 | import { InputPropertyComponent } from '../input-property/input-property.component'; 18 | import { ObjectPropertyComponent } from '../object-property/object-property.component'; 19 | import { PropertyKeyComponent } from '../property-key/property-key.component'; 20 | import { IProperty, PropertyType } from '../property.types'; 21 | import { getDefaultPropertyFromValue, getDefaultPropertyValue } from '../property.utils'; 22 | import { TextareaPropertyComponent } from '../textarea-property/textarea-property.component'; 23 | import { IArrayProperty } from './array-property.types'; 24 | 25 | @Component({ 26 | selector: 'editor-array-property', 27 | templateUrl: './array-property.component.html', 28 | styleUrls: ['./array-property.component.scss'], 29 | changeDetection: ChangeDetectionStrategy.OnPush, 30 | imports: [ 31 | TreeItemComponent, 32 | PropertyKeyComponent, 33 | NgTemplateOutlet, 34 | MatExpansionPanel, 35 | MatExpansionPanelHeader, 36 | MatIconButton, 37 | MatIcon, 38 | MatAccordion, 39 | MatExpansionPanelTitle, 40 | forwardRef(() => ObjectPropertyComponent), 41 | BooleanPropertyComponent, 42 | ChipListPropertyComponent, 43 | TextareaPropertyComponent, 44 | InputPropertyComponent, 45 | MatMenu, 46 | MatMenuItem, 47 | MatMenuTrigger, 48 | ], 49 | }) 50 | export class ArrayPropertyComponent extends ObjectArrayPropertyDirective { 51 | protected defaultValue = []; 52 | 53 | protected get _canAdd(): boolean { 54 | return this.property.canAdd; 55 | } 56 | 57 | onAddChild(type: PropertyType): void { 58 | const childValue: any = getDefaultPropertyValue(type); 59 | const newValue = [...this.currentValue, childValue]; 60 | this._modifyValue(newValue); 61 | 62 | this.isExpanded = true; 63 | } 64 | 65 | onRemoveChild(index: number): void { 66 | const newValue = this.currentValue.filter((_, i) => i !== index); 67 | this._modifyValue(newValue); 68 | } 69 | 70 | protected override _isValidProperty(x: any): x is IArrayProperty { 71 | return this._isBaseProperty(x); 72 | } 73 | 74 | protected _populateChildren(): void { 75 | this.childProperties = this.currentValue.map((childValue, index) => { 76 | let childProperty: IProperty; 77 | if (this.property.childProperty) { 78 | childProperty = structuredClone(this.property.childProperty); 79 | } else { 80 | childProperty = getDefaultPropertyFromValue(childValue ?? ''); 81 | } 82 | childProperty.key = index; 83 | childProperty.isKeyEditable = false; 84 | 85 | return childProperty; 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/array-property/array-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty, IProperty, PropertyCreator, PropertyType } from '../property.types'; 2 | 3 | export interface IArrayProperty extends IBaseProperty { 4 | childProperty?: IProperty; 5 | canAdd?: boolean; 6 | } 7 | 8 | export const createArrayProperty: PropertyCreator = v => ({ 9 | ...v, 10 | type: PropertyType.ARRAY, 11 | }); 12 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/base-property.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, EventEmitter, HostBinding, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; 2 | import { get } from 'lodash-es'; 3 | 4 | import { IBaseProperty, IProperty, IPropertyChange, PropertyChangeType } from './property.types'; 5 | import { isParentProperty } from './property.utils'; 6 | 7 | @Directive() 8 | export abstract class BasePropertyDirective

implements OnChanges { 9 | @Input() treeLevel = 0; 10 | @Input() path: string[] = []; 11 | @Input() target?: Record | any[]; 12 | @Input() treeMode: boolean; 13 | 14 | @Output() public remove = new EventEmitter(); 15 | @Output() public keyChanged = new EventEmitter(); 16 | @Output() public targetChanged = new EventEmitter(); 17 | 18 | protected currentValue: V; 19 | 20 | private _property: P; 21 | 22 | protected abstract defaultValue: V; 23 | 24 | @HostBinding('class.tree-item') get isTreeItem(): boolean { 25 | return this.treeMode; 26 | } 27 | 28 | get property(): P { 29 | return this._property; 30 | } 31 | 32 | @Input() set property(v: IProperty) { 33 | if (this._isValidProperty(v)) { 34 | this._property = v; 35 | } else { 36 | throw new Error(`Invalid property config for ${this.constructor.name}`); 37 | } 38 | } 39 | 40 | ngOnChanges(changes: SimpleChanges): void { 41 | if (changes.target || changes.property) { 42 | const isFirstChange = !!changes.target?.firstChange; 43 | const newValue: any = this.path.length 44 | ? get(this.target, this.path, this.defaultValue) 45 | : (this.target ?? this.defaultValue); 46 | 47 | const canChange: boolean = 48 | isFirstChange || isParentProperty(this.property) || newValue !== this.currentValue; 49 | 50 | if (canChange) { 51 | this.currentValue = newValue; 52 | this._onChanged(isFirstChange); 53 | } 54 | } 55 | } 56 | 57 | public onKeyChanged(newKey: string): void { 58 | this._modifyKey(this.path, [...this.path.slice(0, -1), newKey]); 59 | } 60 | 61 | protected _modifyKey(currPath: string[], newPath: string[]): void { 62 | const change: IPropertyChange = { 63 | type: PropertyChangeType.KEY, 64 | path: currPath, 65 | newPath, 66 | }; 67 | this.targetChanged.emit(change); 68 | } 69 | 70 | protected _modifyValue(value: any, path: string[] = this.path): void { 71 | const change: IPropertyChange = { 72 | type: PropertyChangeType.VALUE, 73 | path, 74 | value, 75 | }; 76 | this.targetChanged.emit(change); 77 | } 78 | 79 | protected _isBaseProperty(x: any): x is IBaseProperty { 80 | return typeof x.type === 'string'; 81 | } 82 | 83 | protected abstract _onChanged(isFirstChange: boolean): void; 84 | protected abstract _isValidProperty(x: any): x is P; 85 | } 86 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/boolean-property/boolean-property.component.html: -------------------------------------------------------------------------------- 1 | @if (treeMode) { 2 | 7 | 8 | 13 | : 14 | 15 | 19 | 20 | 21 | } @else { 22 | 26 | {{ property.name ? property.name : property.key }} 27 | 28 | } 29 | 30 | 34 | @if (property.isRemovable) { 35 | 43 | } 44 | 45 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/boolean-property/boolean-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .simple-checkbox { 6 | margin: 8px 0; 7 | } 8 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/boolean-property/boolean-property.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BooleanPropertyComponent } from './boolean-property.component'; 4 | 5 | describe('BooleanPropertyComponent', () => { 6 | let component: BooleanPropertyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [BooleanPropertyComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(BooleanPropertyComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/boolean-property/boolean-property.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 3 | import { MatCheckbox } from '@angular/material/checkbox'; 4 | import { MatIcon } from '@angular/material/icon'; 5 | import { MatMenu, MatMenuItem } from '@angular/material/menu'; 6 | 7 | import { TreeItemComponent } from '../../tree-item/tree-item.component'; 8 | import { BasePropertyDirective } from '../base-property.directive'; 9 | import { PropertyKeyComponent } from '../property-key/property-key.component'; 10 | import { IBooleanProperty } from './boolean-property.types'; 11 | 12 | @Component({ 13 | selector: 'editor-boolean-property', 14 | templateUrl: './boolean-property.component.html', 15 | styleUrls: ['./boolean-property.component.scss'], 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [TreeItemComponent, PropertyKeyComponent, MatCheckbox, ReactiveFormsModule, MatMenu, MatMenuItem, MatIcon], 18 | }) 19 | export class BooleanPropertyComponent extends BasePropertyDirective { 20 | public formControl: FormControl; 21 | public hasOptions: boolean; 22 | public isInArray: boolean; 23 | 24 | protected defaultValue = false; 25 | 26 | protected _onChanged(isFirstChange: boolean): void { 27 | if (isFirstChange) { 28 | this.formControl = new FormControl(this.currentValue); 29 | this.formControl.valueChanges.subscribe(val => this._modifyValue(val)); 30 | } 31 | 32 | this.hasOptions = this.property.isRemovable; 33 | this.formControl.setValue(this.currentValue, { 34 | emitEvent: false, 35 | }); 36 | } 37 | 38 | protected override _isValidProperty(x: any): x is IBooleanProperty { 39 | return this._isBaseProperty(x); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/boolean-property/boolean-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty, PropertyCreator, PropertyType } from '../property.types'; 2 | 3 | export type IBooleanProperty = IBaseProperty; 4 | 5 | export const createBooleanProperty: PropertyCreator = v => ({ 6 | ...v, 7 | type: PropertyType.BOOLEAN, 8 | }); 9 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/chip-list-property/chip-list-property.component.html: -------------------------------------------------------------------------------- 1 | @if (treeMode) { 2 | 7 | 8 | 13 | : 14 | 15 | 16 | 20 | 21 | 22 | 23 | } @else { 24 | 28 | } 29 | 30 | 35 | 40 | @if (showLabel) { 41 | {{ property.name ? property.name : property.key }} 42 | } 43 | 44 | @for (option of selectedOptions$ | async; track $index) { 45 | 49 | {{ option }} 50 | cancel 51 | 52 | } 53 | 63 | 64 | 69 | @for (option of filteredOptions$ | async; track $index) { 70 | 71 | {{ option }} 72 | 73 | } 74 | 75 | 76 | 77 | 78 | 82 | @if (property.isRemovable) { 83 | 91 | } 92 | 93 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/chip-list-property/chip-list-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | mat-form-field { 6 | width: 100%; 7 | } 8 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/chip-list-property/chip-list-property.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ChipListPropertyComponent } from './chip-list-property.component'; 4 | 5 | describe('ChipListPropertyComponent', () => { 6 | let component: ChipListPropertyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ChipListPropertyComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ChipListPropertyComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/chip-list-property/chip-list-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty } from '../property.types'; 2 | 3 | export interface IChipListProperty extends IBaseProperty { 4 | options: string[]; 5 | outputString?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/collection.property.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input, OnInit, TrackByFunction } from '@angular/core'; 2 | 3 | import { IArrayProperty } from './array-property/array-property.types'; 4 | import { BasePropertyDirective } from './base-property.directive'; 5 | import { IObjectProperty } from './object-property/object-property.types'; 6 | import { IProperty, PropertyType } from './property.types'; 7 | 8 | @Directive() 9 | export abstract class ObjectArrayPropertyDirective

10 | extends BasePropertyDirective 11 | implements OnInit 12 | { 13 | @Input() isExpanded: boolean; 14 | public canAdd: boolean; 15 | public hasOptions: boolean; 16 | public addOptions: PropertyType[] = [ 17 | PropertyType.ARRAY, 18 | PropertyType.BOOLEAN, 19 | PropertyType.NUMBER, 20 | PropertyType.OBJECT, 21 | PropertyType.TEXT, 22 | PropertyType.TEXTAREA, 23 | ]; 24 | 25 | public typeofProperty: typeof PropertyType = PropertyType; 26 | public childProperties: IProperty[] = []; 27 | public childrenTreeLevel: number; 28 | 29 | protected abstract get _canAdd(): boolean; 30 | 31 | trackByPropertyKey: TrackByFunction = (_, property: IProperty) => property.key; 32 | 33 | ngOnInit(): void { 34 | this.childrenTreeLevel = this.treeLevel + 1; 35 | } 36 | 37 | getChildPath(key: string | number): string[] { 38 | return [...this.path, ...key.toString().split('.')]; 39 | } 40 | 41 | protected _onChanged(): void { 42 | this.canAdd = this.property.canAdd; 43 | this.hasOptions = this.property.isRemovable || this._canAdd; 44 | this._populateChildren(); 45 | } 46 | 47 | protected abstract _populateChildren(): void; 48 | } 49 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/expression-properties-property/expression-properties-property.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | {{ (property.name || property.key) + ' { ' + childProperties.length + ' }' }} 9 | 10 | 11 | 17 | 18 | 19 | 20 |

21 | 22 | @for (child of childProperties; track child.key; let index = $index) { 23 | 24 | 25 | 26 | 31 | 32 | 33 | 41 | 42 | 48 | 49 | } 50 | 51 |
52 | 53 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/expression-properties-property/expression-properties-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .simplified-panel { 6 | flex-grow: 1; 7 | } 8 | 9 | .simplified-header { 10 | display: inline-flex; 11 | justify-content: space-between; 12 | align-items: center; 13 | width: 100%; 14 | margin-right: 16px; 15 | padding: 8px 0; 16 | cursor: pointer; 17 | } 18 | 19 | .simplified-header-left { 20 | flex-grow: 1; 21 | } 22 | 23 | .simplified-wrapper { 24 | display: flex; 25 | } 26 | 27 | .line { 28 | width: 1px; 29 | background-color: var(--mat-sys-outline-variant); 30 | margin: 0 14px; 31 | } 32 | 33 | .mat-accordion { 34 | flex-grow: 1; 35 | display: block; 36 | } 37 | 38 | .mat-expansion-panel-header-title { 39 | padding: 0 2px; 40 | } 41 | 42 | .simple-remove-btn { 43 | margin-right: 16px; 44 | } 45 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/expression-properties-property/expression-properties-property.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ExpressionPropertiesPropertyComponent } from './expression-properties-property.component'; 4 | 5 | describe('BooleanPropertyComponent', () => { 6 | let component: ExpressionPropertiesPropertyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ExpressionPropertiesPropertyComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ExpressionPropertiesPropertyComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/expression-properties-property/expression-properties-property.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { MatIconButton } from '@angular/material/button'; 3 | import { 4 | MatAccordion, 5 | MatExpansionPanel, 6 | MatExpansionPanelHeader, 7 | MatExpansionPanelTitle, 8 | } from '@angular/material/expansion'; 9 | import { MatIcon } from '@angular/material/icon'; 10 | 11 | import { TextEditorComponent } from '../../text-editor/text-editor.component'; 12 | import { BasePropertyDirective } from '../base-property.directive'; 13 | import { PropertyKeyComponent } from '../property-key/property-key.component'; 14 | import { IExpressionPropertiesProperty, IExpressionProperty } from './expression-properties-property.types'; 15 | 16 | @Component({ 17 | selector: 'editor-expression-properties-property', 18 | templateUrl: './expression-properties-property.component.html', 19 | styleUrls: ['./expression-properties-property.component.scss'], 20 | changeDetection: ChangeDetectionStrategy.OnPush, 21 | imports: [ 22 | MatExpansionPanel, 23 | MatExpansionPanelHeader, 24 | MatIconButton, 25 | MatIcon, 26 | MatAccordion, 27 | MatExpansionPanelTitle, 28 | PropertyKeyComponent, 29 | TextEditorComponent, 30 | ], 31 | }) 32 | export class ExpressionPropertiesPropertyComponent extends BasePropertyDirective< 33 | IExpressionPropertiesProperty, 34 | Record 35 | > { 36 | public isExpanded: boolean; 37 | public childProperties: IExpressionProperty[] = []; 38 | 39 | protected defaultValue = {}; 40 | 41 | onAddChild(): void { 42 | this.isExpanded = true; 43 | this.onChildKeyChanged('', ''); 44 | } 45 | 46 | onRemoveChild(key: string): void { 47 | this._modifyKey([...this.path, key], undefined); 48 | } 49 | 50 | onChildKeyChanged(currKey: string, newKey: string): void { 51 | this._modifyKey([...this.path, currKey], [...this.path, newKey]); 52 | } 53 | 54 | onChildValueChanged(key: string, value: string): void { 55 | this._modifyValue(value, [...this.path, key]); 56 | } 57 | 58 | protected _onChanged(): void { 59 | this._populateChildrenFromTarget(); 60 | } 61 | 62 | protected override _isValidProperty(x: any): x is IExpressionPropertiesProperty { 63 | return this._isBaseProperty(x); 64 | } 65 | 66 | private _populateChildrenFromTarget() { 67 | this.childProperties = Object.entries(this.currentValue).map(([key, value]) => ({ key, value })); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/expression-properties-property/expression-properties-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty } from '../property.types'; 2 | 3 | export type IExpressionPropertiesProperty = IBaseProperty; 4 | 5 | export interface IExpressionProperty { 6 | key: string; 7 | value: string; 8 | } 9 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/input-property/input-property.component.html: -------------------------------------------------------------------------------- 1 | @if (treeMode) { 2 | 7 | 8 | 13 | : 14 | 15 | 20 | 25 | 26 | 27 | 28 | } @else { 29 | 30 | {{ property.name ? property.name : property.key }} 31 | 37 | 38 | } 39 | 40 | 41 | @if (property.isRemovable) { 42 | 50 | } 51 | 52 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/input-property/input-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | mat-form-field { 6 | width: 100%; 7 | } 8 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/input-property/input-property.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { InputPropertyComponent } from './input-property.component'; 4 | 5 | describe('StringPropertyComponent', () => { 6 | let component: InputPropertyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [InputPropertyComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(InputPropertyComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/input-property/input-property.component.ts: -------------------------------------------------------------------------------- 1 | import { LowerCasePipe } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 3 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 4 | import { MatFormField, MatLabel } from '@angular/material/form-field'; 5 | import { MatIcon } from '@angular/material/icon'; 6 | import { MatInput } from '@angular/material/input'; 7 | import { MatMenu, MatMenuItem } from '@angular/material/menu'; 8 | 9 | import { TreeItemComponent } from '../../tree-item/tree-item.component'; 10 | import { BasePropertyDirective } from '../base-property.directive'; 11 | import { PropertyKeyComponent } from '../property-key/property-key.component'; 12 | import { PropertyType } from '../property.types'; 13 | import { IInputProperty } from './input-property.types'; 14 | 15 | @Component({ 16 | selector: 'editor-input-property', 17 | templateUrl: './input-property.component.html', 18 | styleUrls: ['./input-property.component.scss'], 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | imports: [ 21 | TreeItemComponent, 22 | PropertyKeyComponent, 23 | MatFormField, 24 | MatInput, 25 | ReactiveFormsModule, 26 | MatLabel, 27 | MatMenu, 28 | MatMenuItem, 29 | MatIcon, 30 | LowerCasePipe, 31 | ], 32 | }) 33 | export class InputPropertyComponent extends BasePropertyDirective { 34 | public formControl: FormControl; 35 | public hasOptions: boolean; 36 | public isInArray: boolean; 37 | 38 | protected defaultValue = null; 39 | 40 | protected _onChanged(isFirstChange: boolean): void { 41 | if (isFirstChange) { 42 | this.formControl = new FormControl(this.currentValue?.toString()); 43 | this.formControl.valueChanges.subscribe(val => this._updateValue(val)); 44 | } 45 | 46 | this.hasOptions = this.property.isRemovable; 47 | this.formControl.setValue(this.currentValue?.toString(), { 48 | emitEvent: false, 49 | }); 50 | } 51 | 52 | protected override _isValidProperty(x: any): x is IInputProperty { 53 | return this._isBaseProperty(x); 54 | } 55 | 56 | private _updateValue(value: string): void { 57 | if (this.property.type === PropertyType.NUMBER && isNaN(Number(value))) { 58 | this.formControl.setValue(this.currentValue?.toString(), { 59 | emitEvent: false, 60 | }); 61 | 62 | return; 63 | } 64 | 65 | let newValue: string | number | boolean; 66 | if (value === '') { 67 | newValue = null; 68 | } else if (this.property.outputRawValue) { 69 | if (value.match(`'.*'`)) { 70 | // enforced string (when the value is wrapped in single quotes) 71 | newValue = value.match(`(?<=').*(?=')`)[0]; 72 | } else if (!isNaN(Number(value))) { 73 | // Number 74 | newValue = Number(value); 75 | } else if (value === 'true' || value === 'false') { 76 | // Boolean 77 | newValue = value === 'true'; 78 | } else { 79 | // string 80 | newValue = value; 81 | } 82 | } else { 83 | newValue = value; 84 | } 85 | 86 | this._modifyValue(newValue); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/input-property/input-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty, PropertyCreator, PropertyType } from '../property.types'; 2 | 3 | export interface IInputProperty extends IBaseProperty { 4 | outputRawValue?: boolean; 5 | placeholder?: string; 6 | } 7 | 8 | export const createTextProperty: PropertyCreator = v => ({ 9 | ...v, 10 | type: PropertyType.TEXT, 11 | }); 12 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/object-property/object-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .simplified-properties { 6 | display: flex; 7 | flex-direction: column; 8 | row-gap: 8px; 9 | } 10 | 11 | .simplified-header { 12 | display: inline-flex; 13 | justify-content: space-between; 14 | align-items: center; 15 | width: 100%; 16 | margin-right: 16px; 17 | padding: 8px 0; 18 | cursor: pointer; 19 | } 20 | 21 | .simplified-header-left { 22 | flex-grow: 1; 23 | display: inline-flex; 24 | column-gap: 4px; 25 | } 26 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/object-property/object-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty, IProperty, PropertyCreator, PropertyType } from '../property.types'; 2 | 3 | export interface IObjectProperty extends IBaseProperty { 4 | canAdd?: boolean; 5 | childProperties: IProperty[]; 6 | populateChildrenFromTarget?: boolean; 7 | childrenTreeMode?: boolean; 8 | } 9 | 10 | export const createObjectProperty: PropertyCreator = v => ({ 11 | ...v, 12 | type: PropertyType.OBJECT, 13 | }); 14 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/property-key/property-key.component.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/property-key/property-key.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: inline-flex; 3 | height: var(--editor-tree-item-height); 4 | align-self: flex-start; 5 | align-items: center; 6 | column-gap: 4px; 7 | } 8 | 9 | .key-field { 10 | display: inline-block; 11 | } 12 | 13 | .key-field.editable { 14 | min-width: 32px; 15 | padding: 0.225em; 16 | border-radius: 4px; 17 | cursor: text; 18 | outline: 1px solid var(--mat-sys-outline-variant); 19 | } 20 | 21 | .key-field.editable:hover { 22 | outline: 2px solid var(--mat-sys-outline); 23 | } 24 | 25 | .key-field.editable:focus { 26 | outline: 2px solid var(--mat-sys-primary); 27 | } 28 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/property-key/property-key.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PropertyKeyComponent } from './property-key.component'; 4 | 5 | describe('PropertyKeyComponent', () => { 6 | let component: PropertyKeyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [PropertyKeyComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PropertyKeyComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/property-key/property-key.component.ts: -------------------------------------------------------------------------------- 1 | import { NgClass } from '@angular/common'; 2 | import { 3 | AfterViewInit, 4 | Component, 5 | ElementRef, 6 | EventEmitter, 7 | Input, 8 | OnChanges, 9 | Output, 10 | SimpleChanges, 11 | ViewChild, 12 | } from '@angular/core'; 13 | import { isNil } from 'lodash-es'; 14 | 15 | @Component({ 16 | selector: 'editor-property-key', 17 | templateUrl: './property-key.component.html', 18 | styleUrls: ['./property-key.component.scss'], 19 | imports: [NgClass], 20 | }) 21 | export class PropertyKeyComponent implements OnChanges, AfterViewInit { 22 | @Input() isEditable: boolean; 23 | 24 | @Output() keyChange = new EventEmitter(); 25 | 26 | @ViewChild('keyElement') keyElementRef: ElementRef; 27 | 28 | private _key: string | number; 29 | 30 | @Input() 31 | get key() { 32 | return this._key; 33 | } 34 | set key(val: string | number) { 35 | this._key = !isNil(val) ? val : ''; 36 | } 37 | 38 | ngOnChanges(changes: SimpleChanges): void { 39 | if (changes.key && !changes.key.firstChange) { 40 | this.keyElementRef.nativeElement.innerText = this.key + ''; 41 | } 42 | } 43 | 44 | ngAfterViewInit(): void { 45 | this.keyElementRef.nativeElement.innerText = this.key + ''; 46 | this.keyElementRef.nativeElement.addEventListener('blur', () => { 47 | const text = this.keyElementRef.nativeElement.innerText; 48 | if (text !== this.key) { 49 | this.key = text; 50 | this.keyChange.emit(this.key?.toString()); 51 | } 52 | }); 53 | this.keyElementRef.nativeElement.addEventListener('click', e => e.stopPropagation()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/property.types.ts: -------------------------------------------------------------------------------- 1 | import { IArrayProperty } from './array-property/array-property.types'; 2 | import { IBooleanProperty } from './boolean-property/boolean-property.types'; 3 | import { IChipListProperty } from './chip-list-property/chip-list-property.types'; 4 | import { IExpressionPropertiesProperty } from './expression-properties-property/expression-properties-property.types'; 5 | import { IInputProperty } from './input-property/input-property.types'; 6 | import { IObjectProperty } from './object-property/object-property.types'; 7 | import { ISelectProperty } from './select-property/select-property.types'; 8 | import { IValidatorsProperty } from './validators-property/validators-property.types'; 9 | 10 | export enum PropertyType { 11 | ARRAY = 'Array', 12 | BOOLEAN = 'Boolean', 13 | CHIP_LIST = 'Chip List', 14 | EXPRESSION_PROPERTIES = 'Expression Properties', 15 | OBJECT = 'Object', 16 | NUMBER = 'Number', 17 | SELECT = 'Select', 18 | TEXT = 'Text', 19 | TEXTAREA = 'Textarea', 20 | VALIDATORS = 'Validators', 21 | } 22 | 23 | export enum PropertyChangeType { 24 | KEY, 25 | VALUE, 26 | } 27 | 28 | export interface IBaseProperty { 29 | type: PropertyType; 30 | name?: string; 31 | key?: string | number; 32 | isRemovable?: boolean; 33 | isKeyEditable?: boolean; 34 | } 35 | 36 | export interface IPropertyChange { 37 | type: PropertyChangeType; 38 | path: string[]; 39 | newPath?: string[]; 40 | value?: any; 41 | } 42 | 43 | export type IProperty = 44 | | IArrayProperty 45 | | IBooleanProperty 46 | | IChipListProperty 47 | | IObjectProperty 48 | | IInputProperty 49 | | ISelectProperty 50 | | IExpressionPropertiesProperty 51 | | IValidatorsProperty; 52 | 53 | export type PropertyCreator = (property: Omit) => T; 54 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/select-property/select-property.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{ property.name ? property.name : property.key }} 3 | 4 | @for (option of property.options; track option.value) { 5 | {{ option.label }} 6 | } 7 | 8 | 15 | 16 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/select-property/select-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | mat-form-field { 6 | width: 100%; 7 | } 8 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/select-property/select-property.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SelectPropertyComponent } from './select-property.component'; 4 | 5 | describe('SelectPropertyComponent', () => { 6 | let component: SelectPropertyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [SelectPropertyComponent], 12 | }).compileComponents(); 13 | 14 | fixture = TestBed.createComponent(SelectPropertyComponent); 15 | component = fixture.componentInstance; 16 | fixture.detectChanges(); 17 | }); 18 | 19 | it('should create', () => { 20 | expect(component).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/select-property/select-property.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 3 | import { MatIconButton } from '@angular/material/button'; 4 | import { MatOption } from '@angular/material/core'; 5 | import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'; 6 | import { MatIcon } from '@angular/material/icon'; 7 | import { MatSelect } from '@angular/material/select'; 8 | 9 | import { BasePropertyDirective } from '../base-property.directive'; 10 | import { ISelectProperty, ISelectPropertyOption } from './select-property.types'; 11 | 12 | @Component({ 13 | selector: 'editor-select-property', 14 | templateUrl: './select-property.component.html', 15 | styleUrls: ['./select-property.component.scss'], 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [MatFormField, MatLabel, MatSelect, ReactiveFormsModule, MatOption, MatIconButton, MatSuffix, MatIcon], 18 | }) 19 | export class SelectPropertyComponent extends BasePropertyDirective { 20 | public formControl: FormControl; 21 | public hasOptions: boolean; 22 | 23 | protected defaultValue = undefined; 24 | 25 | onClear(): void { 26 | this.formControl.setValue(undefined); 27 | } 28 | 29 | protected _onChanged(isFirstChange: boolean): void { 30 | if (isFirstChange) { 31 | this.formControl = new FormControl(this.currentValue); 32 | this.formControl.valueChanges.subscribe(val => this._modifyValue(val)); 33 | } 34 | 35 | this.hasOptions = this.property.isRemovable; 36 | this.formControl.setValue(this.currentValue, { 37 | emitEvent: false, 38 | }); 39 | } 40 | 41 | protected override _isValidProperty(x: any): x is ISelectProperty { 42 | return Array.isArray(x.options) && x.options.every(this._isValidOption) && this._isBaseProperty(x); 43 | } 44 | 45 | private _isValidOption(x: any): x is ISelectPropertyOption { 46 | return typeof x.label === 'string' && (typeof x.value === 'string' || typeof x.value === 'number'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/select-property/select-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty, PropertyCreator, PropertyType } from '../property.types'; 2 | 3 | export interface ISelectProperty extends IBaseProperty { 4 | options: ISelectPropertyOption[]; 5 | } 6 | 7 | export interface ISelectPropertyOption { 8 | label: string; 9 | value: string | number; 10 | } 11 | 12 | export const createSelectProperty: PropertyCreator = v => ({ 13 | ...v, 14 | type: PropertyType.SELECT, 15 | }); 16 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/textarea-property/textarea-property.component.html: -------------------------------------------------------------------------------- 1 | @if (treeMode) { 2 | 7 | 8 | 13 | : 14 | 15 | 20 | 28 | 29 | 30 | 31 | } @else { 32 | 33 | {{ property.name ? property.name : property.key }} 34 | 42 | 43 | } 44 | 45 | 49 | @if (property.isRemovable) { 50 | 58 | } 59 | 60 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/textarea-property/textarea-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/textarea-property/textarea-property.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TextareaPropertyComponent } from './textarea-property.component'; 4 | 5 | describe('TextareaPropertyComponent', () => { 6 | let component: TextareaPropertyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TextareaPropertyComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(TextareaPropertyComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/textarea-property/textarea-property.component.ts: -------------------------------------------------------------------------------- 1 | import { CdkTextareaAutosize } from '@angular/cdk/text-field'; 2 | 3 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 4 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 5 | import { MatFormField, MatLabel } from '@angular/material/form-field'; 6 | import { MatIcon } from '@angular/material/icon'; 7 | import { MatInput } from '@angular/material/input'; 8 | import { MatMenu, MatMenuItem } from '@angular/material/menu'; 9 | 10 | import { TreeItemComponent } from '../../tree-item/tree-item.component'; 11 | import { BasePropertyDirective } from '../base-property.directive'; 12 | import { PropertyKeyComponent } from '../property-key/property-key.component'; 13 | import { ITextareaProperty } from './textarea-property.types'; 14 | 15 | @Component({ 16 | selector: 'editor-textarea-property', 17 | templateUrl: './textarea-property.component.html', 18 | styleUrls: ['./textarea-property.component.scss'], 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | imports: [ 21 | TreeItemComponent, 22 | PropertyKeyComponent, 23 | MatFormField, 24 | MatInput, 25 | CdkTextareaAutosize, 26 | ReactiveFormsModule, 27 | MatLabel, 28 | MatMenu, 29 | MatMenuItem, 30 | MatIcon, 31 | ], 32 | }) 33 | export class TextareaPropertyComponent extends BasePropertyDirective { 34 | public formControl: FormControl; 35 | public hasOptions: boolean; 36 | 37 | protected defaultValue = ''; 38 | 39 | protected _onChanged(isFirstChange: boolean): void { 40 | if (isFirstChange) { 41 | this.formControl = new FormControl(this.currentValue); 42 | this.formControl.valueChanges.subscribe(val => this._modifyValue(val)); 43 | } 44 | 45 | this.hasOptions = this.property.isRemovable; 46 | this.formControl.setValue(this.currentValue, { 47 | emitEvent: false, 48 | }); 49 | } 50 | 51 | protected override _isValidProperty(x: any): x is ITextareaProperty { 52 | return this._isBaseProperty(x); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/textarea-property/textarea-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IBaseProperty } from '../property.types'; 2 | 3 | export interface ITextareaProperty extends IBaseProperty { 4 | maxRows?: number; 5 | minRows?: number; 6 | } 7 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/validators-property/validators-property.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ property.name || property.key }} 6 | {{ '{ ' + validationConfigs.length + ' }' }} 7 | 8 | @if (addOptions.length) { 9 | 16 | } 17 | 18 | 19 | @for (config of validationConfigs; track config.data.name; let i = $index) { 20 | 27 | 28 | } 29 | 30 | 31 | 35 | @for (option of addOptions; track option.key) { 36 | 42 | } 43 | 44 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/validators-property/validators-property.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .simplified-header { 6 | display: inline-flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | width: 100%; 10 | margin-right: 16px; 11 | padding: 8px 0; 12 | cursor: pointer; 13 | } 14 | 15 | .simplified-header-left { 16 | flex-grow: 1; 17 | display: inline-flex; 18 | column-gap: 4px; 19 | } 20 | -------------------------------------------------------------------------------- /projects/editor/src/lib/property/validators-property/validators-property.types.ts: -------------------------------------------------------------------------------- 1 | import { IObjectProperty } from '../object-property/object-property.types'; 2 | import { IBaseProperty } from '../property.types'; 3 | 4 | export type IValidatorsProperty = IBaseProperty; 5 | 6 | export interface IValidatorsValue { 7 | [key: string]: IValidationData | string[]; 8 | validation?: string[]; 9 | } 10 | 11 | export interface IValidationData { 12 | name: string; 13 | message?: string; 14 | options?: Record; 15 | } 16 | 17 | export interface IValidationConfig { 18 | data: IValidationData; 19 | property: IObjectProperty; 20 | } 21 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar-section/sidebar-section.component.html: -------------------------------------------------------------------------------- 1 | @if (index !== 0) { 2 | 6 | } 7 | 29 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar-section/sidebar-section.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | transition: height 0.15s ease-out; 4 | } 5 | 6 | .sidebar-divider { 7 | position: relative; 8 | height: 100%; 9 | height: 4px; 10 | background-color: var(--mat-sys-outline-variant); 11 | cursor: ns-resize; 12 | z-index: 1; 13 | } 14 | 15 | .sidebar-divider::after { 16 | position: absolute; 17 | content: ''; 18 | height: 4px; 19 | width: 100%; 20 | top: 0; 21 | background-color: transparent; 22 | transition: 0.15s ease-out; 23 | } 24 | 25 | .sidebar-divider:hover::after { 26 | height: 16px; 27 | top: -6px; 28 | background-color: color-mix(in srgb, var(--mat-sys-outline-variant), rgb(0, 0, 0, 0.2)); 29 | } 30 | 31 | .sidebar-section-wrapper { 32 | overflow: hidden; 33 | height: calc(100% - 4px); 34 | 35 | &.first { 36 | height: 100%; 37 | } 38 | } 39 | 40 | .sidebar-header { 41 | font-size: var(--mat-sys-title-small-size); 42 | font-weight: var(--mat-sys-title-small-weight); 43 | height: calc(var(--editor-header-height) + 1px); 44 | display: flex; 45 | align-items: center; 46 | column-gap: 8px; 47 | padding: 0 12px; 48 | border-bottom: 1px solid var(--mat-sys-outline-variant); 49 | box-sizing: border-box; 50 | user-select: none; 51 | cursor: pointer; 52 | } 53 | 54 | .sidebar-header-left { 55 | display: inline-flex; 56 | column-gap: 8px; 57 | align-items: center; 58 | } 59 | 60 | .sidebar-section-content { 61 | display: block; 62 | overflow: auto; 63 | } 64 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar-section/sidebar-section.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SidebarSectionComponent } from './sidebar-section.component'; 4 | 5 | describe('SidebarSectionComponent', () => { 6 | let component: SidebarSectionComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [SidebarSectionComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(SidebarSectionComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar-section/sidebar-section.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | ChangeDetectorRef, 4 | Component, 5 | ElementRef, 6 | HostBinding, 7 | Input, 8 | OnChanges, 9 | SimpleChanges, 10 | } from '@angular/core'; 11 | import { MatIcon } from '@angular/material/icon'; 12 | 13 | import { SidebarComponent } from '../sidebar.component'; 14 | 15 | @Component({ 16 | selector: 'editor-sidebar-section', 17 | templateUrl: './sidebar-section.component.html', 18 | styleUrls: ['./sidebar-section.component.scss'], 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | imports: [MatIcon], 21 | }) 22 | export class SidebarSectionComponent implements OnChanges { 23 | @Input() isCollapsed: boolean; 24 | 25 | public element: HTMLElement; 26 | public index = 0; 27 | public cachedCollapseHeight: number; 28 | public minContentHeight: number; 29 | public maxHeight = 0; 30 | public sideBar: SidebarComponent; 31 | 32 | private readonly _headerHeight = 44; 33 | private readonly _dividerHeight = 4; 34 | private _height: number; 35 | 36 | constructor( 37 | private _cdRef: ChangeDetectorRef, 38 | elementRef: ElementRef 39 | ) { 40 | this.element = elementRef.nativeElement; 41 | } 42 | 43 | public get headerHeight(): number { 44 | return this._headerHeight + (this.index === 0 ? 0 : this._dividerHeight); 45 | } 46 | 47 | public get availableHeight(): number { 48 | return this.height - this.headerHeight - (this.isCollapsed ? 0 : this.minContentHeight); 49 | } 50 | 51 | @HostBinding('style.height.px') 52 | public get height(): number { 53 | return this._height; 54 | } 55 | public set height(val: number) { 56 | this._height = val; 57 | this._cdRef.markForCheck(); 58 | } 59 | 60 | ngOnChanges(changes: SimpleChanges): void { 61 | if (changes.isCollapsed && !changes.isCollapsed.firstChange) { 62 | this.sideBar.toggleSection(this); 63 | } 64 | } 65 | 66 | onSectionMouseDown(event: MouseEvent): void { 67 | this.sideBar.onSectionMouseDown(event, this); 68 | } 69 | 70 | onToggleExpansionClicked(): void { 71 | this.sideBar.toggleSection(this); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar.component.html: -------------------------------------------------------------------------------- 1 | @if (position === typeOfSideBarPosition.RIGHT) { 2 | 6 | } 7 | 10 | @if (position === typeOfSideBarPosition.LEFT) { 11 | 15 | } 16 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | flex-shrink: 0; 3 | display: flex; 4 | height: 100%; 5 | } 6 | 7 | .sidebar-content { 8 | display: block; 9 | height: 100%; 10 | width: calc(100% - 4px); 11 | } 12 | 13 | .sidebar-divider { 14 | position: relative; 15 | height: 100%; 16 | width: 4px; 17 | flex-shrink: 0; 18 | background-color: var(--mat-sys-outline-variant); 19 | cursor: ew-resize; 20 | } 21 | 22 | .sidebar-divider::after { 23 | position: absolute; 24 | content: ''; 25 | height: 100%; 26 | width: 4px; 27 | left: 0; 28 | background-color: transparent; 29 | transition: 0.15s ease-out; 30 | } 31 | 32 | .sidebar-divider:hover::after { 33 | width: 16px; 34 | left: -6px; 35 | background-color: color-mix(in srgb, var(--mat-sys-outline-variant), rgb(0, 0, 0, 0.2)); 36 | } 37 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SidebarComponent } from './sidebar.component'; 4 | 5 | describe('SidebarComponent', () => { 6 | let component: SidebarComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [SidebarComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(SidebarComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/sidebar/sidebar.types.ts: -------------------------------------------------------------------------------- 1 | export enum SideBarPosition { 2 | LEFT, 3 | RIGHT, 4 | } 5 | -------------------------------------------------------------------------------- /projects/editor/src/lib/state/state.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { FormlyFieldConfig } from '@ngx-formly/core'; 3 | 4 | import { FieldTypeOption, GetDefaultField, IEditorFormlyField } from '../editor.types'; 5 | import { IPropertyChange } from '../property/property.types'; 6 | 7 | export interface EditorAction { 8 | editorId: string; 9 | } 10 | 11 | export interface AddForm extends EditorAction { 12 | name: string; 13 | sourceFields?: FormlyFieldConfig[]; 14 | model?: object; 15 | typeOptions: FieldTypeOption[]; 16 | defaultTypeOption: FieldTypeOption; 17 | getDefaultField: GetDefaultField; 18 | } 19 | export interface RemoveForm extends EditorAction { 20 | formId: string; 21 | } 22 | export interface DuplicateForm extends EditorAction { 23 | formId: string; 24 | } 25 | export interface SetActiveFormId extends EditorAction { 26 | activeFormId: string; 27 | } 28 | export interface AddField extends EditorAction { 29 | fieldType: string; 30 | parent?: IEditorFormlyField; 31 | index?: number; 32 | typeOptions: FieldTypeOption[]; 33 | defaultTypeOption: FieldTypeOption; 34 | getDefaultField: GetDefaultField; 35 | } 36 | export interface RemoveField extends EditorAction { 37 | fieldId: string; 38 | parent?: IEditorFormlyField; 39 | keyPath?: string; 40 | } 41 | export interface SetEditMode extends EditorAction { 42 | formId: string; 43 | isEditMode: boolean; 44 | } 45 | export interface ModifyActiveField extends EditorAction { 46 | activeField: IEditorFormlyField; 47 | change: IPropertyChange; 48 | } 49 | export interface SetActiveField extends EditorAction { 50 | activeFieldId: string; 51 | } 52 | export interface ModifyActiveModel extends EditorAction { 53 | change: IPropertyChange; 54 | } 55 | export interface SetActiveModel extends EditorAction { 56 | model: Record; 57 | } 58 | export interface ReplaceField extends EditorAction { 59 | field: IEditorFormlyField; 60 | parent?: IEditorFormlyField; 61 | newFieldType: string; 62 | typeOptions: FieldTypeOption[]; 63 | defaultTypeOption: FieldTypeOption; 64 | keyPath?: string; 65 | getDefaultField: GetDefaultField; 66 | } 67 | export interface MoveField extends EditorAction { 68 | sourceField: IEditorFormlyField; 69 | sourceParent: IEditorFormlyField; 70 | targetParent?: IEditorFormlyField; 71 | sourceIndex: number; 72 | targetIndex?: number; 73 | } 74 | 75 | export const addForm = createAction('[Editor] Add Form', props()); 76 | export const removeForm = createAction('[Editor] Remove Form', props()); 77 | export const duplicateForm = createAction('[Editor] Duplicate Form', props()); 78 | export const setActiveFormId = createAction('[Editor] Set Active Form ID', props()); 79 | export const setEditMode = createAction('[Editor] Set Edit Mode', props()); 80 | 81 | export const addField = createAction('[Editor] Add Field', props()); 82 | export const removeField = createAction('[Editor] Remove Field', props()); 83 | export const modifyActiveField = createAction('[Editor] Modify Active Field', props()); 84 | export const setActiveField = createAction('[Editor] Set Active Field', props()); 85 | export const replaceField = createAction('[Editor] Replace Field', props()); 86 | export const moveField = createAction('[Editor] Move Field', props()); 87 | 88 | export const modifyActiveModel = createAction('[Editor] Modify Active Model', props()); 89 | export const setActiveModel = createAction('[Editor] Set Active Model', props()); 90 | -------------------------------------------------------------------------------- /projects/editor/src/lib/state/state.types.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | 3 | import { IForm } from '../editor.types'; 4 | import { createEditorFeature } from './state.reducers'; 5 | 6 | export interface IEditorState { 7 | formMap: Readonly>; 8 | activeFormId: Readonly; 9 | } 10 | 11 | export type EditorFeature = ReturnType; 12 | export const EDITOR_FEATURE = new InjectionToken('EDITOR_FEATURE'); 13 | -------------------------------------------------------------------------------- /projects/editor/src/lib/text-editor/text-editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TextEditorComponent } from './text-editor.component'; 4 | 5 | describe('TextEditorComponent', () => { 6 | let component: TextEditorComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TextEditorComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(TextEditorComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/tree-item/tree-item.component.html: -------------------------------------------------------------------------------- 1 |
5 | @for (position of sideLinePositions; track position) { 6 | 10 | } 11 | 26 |
27 | 28 |
29 | @if (hasOptions) { 30 | 39 | } 40 |
41 | @if (isExpandable) { 42 |
46 | 47 |
48 | } 49 | -------------------------------------------------------------------------------- /projects/editor/src/lib/tree-item/tree-item.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .header { 6 | display: flex; 7 | min-height: var(--editor-tree-item-height); 8 | position: relative; 9 | column-gap: 4px; 10 | align-items: center; 11 | padding: 4px; 12 | white-space: nowrap; 13 | border-radius: 4px; 14 | 15 | &:hover { 16 | background-color: var(--mat-sys-surface-container-low); 17 | } 18 | 19 | &.active { 20 | background-color: var(--mat-sys-surface-container); 21 | border-radius: 4px; 22 | } 23 | 24 | &.active:hover { 25 | background-color: var(--mat-sys-surface-container-highest); 26 | border-radius: 4px; 27 | } 28 | } 29 | 30 | .tree-item-header-content { 31 | font-size: var(--mat-sys-body-medium-size); 32 | font-weight: var(--mat-sys-body-medium-weight); 33 | flex-grow: 1; 34 | display: flex; 35 | align-items: center; 36 | column-gap: 4px; 37 | } 38 | 39 | .tree-item-header-options { 40 | position: sticky; 41 | right: 4px; 42 | background-color: var(--mat-sys-surface-container-lowest); 43 | box-shadow: 0px 2px 4px -1px rgba(0, 0, 0, 0.2), 0px 4px 5px 0px rgba(0, 0, 0, 0.14), 44 | 0px 1px 10px 0px rgba(0, 0, 0, 0.12); 45 | visibility: hidden; 46 | } 47 | 48 | .tree-item-header-options.visible { 49 | visibility: visible; 50 | } 51 | 52 | .tree-item-side-line { 53 | position: absolute; 54 | height: 100%; 55 | border-left: 1px solid var(--mat-sys-outline-variant); 56 | } 57 | 58 | .children-collapsed { 59 | overflow-y: hidden; 60 | } 61 | -------------------------------------------------------------------------------- /projects/editor/src/lib/tree-item/tree-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TreeItemComponent } from './tree-item.component'; 4 | 5 | describe('TreeItemHeaderComponent', () => { 6 | let component: TreeItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TreeItemComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(TreeItemComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /projects/editor/src/lib/tree-item/tree-item.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | EventEmitter, 5 | HostBinding, 6 | HostListener, 7 | Input, 8 | OnInit, 9 | Output, 10 | } from '@angular/core'; 11 | import { MatIconButton } from '@angular/material/button'; 12 | import { MatExpansionPanelState, matExpansionAnimations } from '@angular/material/expansion'; 13 | import { MatIcon } from '@angular/material/icon'; 14 | import { MatMenuPanel, MatMenuTrigger } from '@angular/material/menu'; 15 | 16 | @Component({ 17 | selector: 'editor-tree-item', 18 | templateUrl: './tree-item.component.html', 19 | styleUrls: ['./tree-item.component.scss'], 20 | animations: [matExpansionAnimations.bodyExpansion], 21 | changeDetection: ChangeDetectionStrategy.OnPush, 22 | imports: [MatIconButton, MatIcon, MatMenuTrigger], 23 | }) 24 | export class TreeItemComponent implements OnInit { 25 | @Input() treeLevel = 0; 26 | @Input() isExpanded: boolean; 27 | @Input() isExpandable: boolean; 28 | @Input() hasOptions: boolean; 29 | @Input() optionsMenu: MatMenuPanel; 30 | @Input() @HostBinding('class.active') isActive: boolean; 31 | 32 | @Output() isExpandedChange = new EventEmitter(); 33 | 34 | public treeLevelPadding: number; 35 | public sideLinePositions: number[]; 36 | public isMouseInside: boolean; 37 | 38 | private readonly _treeIndentation = 18; 39 | 40 | get expansionState(): MatExpansionPanelState { 41 | return this.isExpanded ? 'expanded' : 'collapsed'; 42 | } 43 | 44 | @HostListener('mouseover', ['$event']) 45 | onMouseOver(event: MouseEvent): void { 46 | this.isMouseInside = true; 47 | event.stopPropagation(); 48 | } 49 | 50 | @HostListener('mouseout') 51 | onMouseOut(): void { 52 | this.isMouseInside = false; 53 | } 54 | 55 | @HostListener('click', ['$event']) 56 | onClicked(event: MouseEvent): void { 57 | if (this.isActive !== false) { 58 | this.onToggle(event); 59 | } else { 60 | this.isExpanded = true; 61 | this.isExpandedChange.emit(this.isExpanded); 62 | event.stopPropagation(); 63 | } 64 | } 65 | 66 | ngOnInit(): void { 67 | this.treeLevelPadding = this._treeIndentation * this.treeLevel; 68 | this.sideLinePositions = [...Array(this.treeLevel).keys()].map(i => (i + 1) * this._treeIndentation); 69 | } 70 | 71 | onToggle(event: MouseEvent): void { 72 | this.isExpanded = !this.isExpanded; 73 | this.isExpandedChange.emit(this.isExpanded); 74 | event.stopPropagation(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /projects/editor/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of editor 3 | */ 4 | 5 | export * from './lib/property/array-property/array-property.types'; 6 | export * from './lib/property/boolean-property/boolean-property.types'; 7 | export * from './lib/property/input-property/input-property.types'; 8 | export * from './lib/property/object-property/object-property.types'; 9 | export { IBaseProperty, IProperty } from './lib/property/property.types'; 10 | export * from './lib/property/select-property/select-property.types'; 11 | 12 | export { bootstrapConfig } from './lib/edit-field/styles/styles-config/bootstrap.styles-config'; 13 | export { tailwindConfig } from './lib/edit-field/styles/styles-config/tailwind.styles-config'; 14 | export * from './lib/edit-field/styles/styles.types'; 15 | 16 | export { FormlyGroupComponent } from './lib/custom-formly/formly-group/formly-group.component'; 17 | export { EditorComponent } from './lib/editor.component'; 18 | export { 19 | EditorConfig, 20 | FieldCategoryOption, 21 | FieldTypeChildrenConfig, 22 | FieldTypeOption, 23 | FieldWrapperOption, 24 | IDefaultForm, 25 | IEditorFormlyField, 26 | ValidatorOption, 27 | } from './lib/editor.types'; 28 | 29 | export { EditorModule } from './lib/editor.module'; 30 | export { provideEditor, provideEditorConfig, withConfig } from './lib/editor.provider'; 31 | -------------------------------------------------------------------------------- /projects/editor/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import { getTestBed } from '@angular/core/testing'; 4 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 5 | import 'zone.js'; 6 | import 'zone.js/testing'; 7 | 8 | // First, initialize the Angular testing environment. 9 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 10 | teardown: { destroyAfterEach: false }, 11 | }); 12 | -------------------------------------------------------------------------------- /projects/editor/tailwind.lib.config.js: -------------------------------------------------------------------------------- 1 | // Plugins 2 | const layout = [ 3 | 'display', 4 | 'flexDirection', 5 | 'gap', 6 | 'gridColumn', 7 | 'gridColumnEnd', 8 | 'gridColumnStart', 9 | 'gridRow', 10 | 'gridRowEnd', 11 | 'gridRowStart', 12 | 'gridTemplateColumns', 13 | 'gridTemplateRows', 14 | ]; 15 | const spacing = ['padding', 'margin']; 16 | const sizing = ['width', 'height']; 17 | 18 | /** @type {import('tailwindcss').Config} */ 19 | module.exports = { 20 | prefix: 'tw-', 21 | safelist: [ 22 | { 23 | pattern: /./, 24 | variants: ['sm', 'md', 'lg', 'xl'], 25 | }, 26 | ], 27 | theme: { 28 | screens: { 29 | sm: '640px', 30 | md: '768px', 31 | lg: '1024px', 32 | xl: '1280px', 33 | }, 34 | }, 35 | corePlugins: [...layout, ...spacing, ...sizing], 36 | }; 37 | -------------------------------------------------------------------------------- /projects/editor/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "types": [], 10 | "lib": ["dom", "es2020"] 11 | }, 12 | "angularCompilerOptions": { 13 | "skipTemplateCodegen": true, 14 | "strictMetadataEmit": true, 15 | "enableResourceInlining": true, 16 | "fullTemplateTypeCheck": true 17 | }, 18 | "exclude": ["src/test.ts", "**/*.spec.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /projects/editor/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/editor/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "files": ["src/test.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/editor/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "lib", "camelCase"], 5 | "component-selector": [true, "element", "lib", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
Formly Editor
3 |
4 | 5 | Formly UI 6 | 7 | 11 | Material 12 | 13 | 17 | Bootstrap 18 | 19 | 20 | 21 | 26 | 27 | 28 |
29 |
30 |
31 | 32 |
33 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | $app-header-height: 64px; 2 | 3 | :host { 4 | display: block; 5 | height: 100%; 6 | } 7 | 8 | .app-header { 9 | height: $app-header-height - 1px; 10 | display: flex; 11 | justify-content: space-between; 12 | align-items: center; 13 | padding: 0 16px; 14 | box-sizing: border-box; 15 | border-bottom: 1px solid var(--mat-sys-outline-variant); 16 | } 17 | 18 | .app-editor-container { 19 | height: calc(100% - $app-header-height); 20 | } 21 | 22 | .app-title { 23 | font-size: 1.25em; 24 | font-weight: 500; 25 | } 26 | 27 | .app-actions { 28 | display: flex; 29 | align-items: center; 30 | column-gap: 16px; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | imports: [RouterTestingModule, AppComponent], 9 | }).compileComponents(); 10 | }); 11 | 12 | it('should create the app', () => { 13 | const fixture: ComponentFixture = TestBed.createComponent(AppComponent); 14 | const app: AppComponent = fixture.componentInstance; 15 | expect(app).toBeTruthy(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { Component } from '@angular/core'; 3 | import { MatOption } from '@angular/material/core'; 4 | import { MatFormField, MatLabel } from '@angular/material/form-field'; 5 | import { MatIcon, MatIconRegistry } from '@angular/material/icon'; 6 | import { MatSelect } from '@angular/material/select'; 7 | import { DomSanitizer } from '@angular/platform-browser'; 8 | import { Event, NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router'; 9 | import { Observable, filter, map } from 'rxjs'; 10 | 11 | @Component({ 12 | selector: 'app-root', 13 | templateUrl: './app.component.html', 14 | styleUrls: ['./app.component.scss'], 15 | imports: [MatFormField, MatLabel, MatSelect, MatOption, RouterLink, MatIcon, RouterOutlet, AsyncPipe], 16 | }) 17 | export class AppComponent { 18 | currPath$: Observable; 19 | 20 | constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer, router: Router) { 21 | this.currPath$ = router.events.pipe( 22 | filter((e: Event) => e instanceof NavigationEnd), 23 | map((v: NavigationEnd) => v.urlAfterRedirects.replace('/', '')) 24 | ); 25 | iconRegistry.addSvgIcon('github', sanitizer.bypassSecurityTrustResourceUrl('assets/img/github-mark.svg')); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient } from '@angular/common/http'; 2 | import { ApplicationConfig } from '@angular/core'; 3 | import { provideAnimations } from '@angular/platform-browser/animations'; 4 | import { provideRouter } from '@angular/router'; 5 | import { provideEditor } from '@sesan07/ngx-formly-editor'; 6 | 7 | import { routes } from './app.routes'; 8 | 9 | export const appConfig: ApplicationConfig = { 10 | providers: [provideHttpClient(), provideAnimations(), provideRouter(routes), provideEditor()], 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { provideEditorConfig } from '@sesan07/ngx-formly-editor'; 3 | 4 | import { bootstrapEditorConfig } from './bootstrap/bootstrap.config'; 5 | import { provideBootstrap } from './bootstrap/bootstrap.provider'; 6 | import { materialEditorConfig } from './material/material.config'; 7 | import { provideMaterial } from './material/material.provider'; 8 | 9 | export const routes: Routes = [ 10 | { 11 | path: 'bootstrap', 12 | loadComponent: () => import('./bootstrap/bootstrap.component').then(m => m.BootstrapComponent), 13 | providers: [provideBootstrap(), provideEditorConfig(bootstrapEditorConfig)], 14 | }, 15 | { 16 | path: 'material', 17 | loadComponent: () => import('./material/material.component').then(m => m.MaterialComponent), 18 | providers: [provideMaterial(), provideEditorConfig(materialEditorConfig)], 19 | }, 20 | { path: '**', redirectTo: 'material' }, 21 | ]; 22 | -------------------------------------------------------------------------------- /src/app/bootstrap/bootstrap.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { EditorComponent } from '@sesan07/ngx-formly-editor'; 3 | 4 | @Component({ 5 | selector: 'app-bootstrap', 6 | template: ` `, 7 | imports: [EditorComponent], 8 | }) 9 | export class BootstrapComponent {} 10 | -------------------------------------------------------------------------------- /src/app/bootstrap/bootstrap.config.ts: -------------------------------------------------------------------------------- 1 | import { EditorConfig, bootstrapConfig as stylesConfig } from '@sesan07/ngx-formly-editor'; 2 | 3 | import { 4 | checkboxTypeConfig, 5 | formFieldWrapperConfig, 6 | groupTypeConfig, 7 | inputTypeConfig, 8 | numberTypeConfig, 9 | radioTypeConfig, 10 | selectTypeConfig, 11 | textareaTypeConfig, 12 | } from '../material/material.config'; 13 | import { defaultForm } from './bootstrap.form'; 14 | 15 | export const bootstrapEditorConfig: EditorConfig = { 16 | id: 'editor-bootstrap', 17 | fieldOptions: [ 18 | { 19 | displayName: 'Input', 20 | children: [inputTypeConfig, numberTypeConfig], 21 | }, 22 | checkboxTypeConfig, 23 | radioTypeConfig, 24 | selectTypeConfig, 25 | textareaTypeConfig, 26 | groupTypeConfig, 27 | ], 28 | wrapperOptions: [formFieldWrapperConfig], 29 | defaultForm, 30 | stylesConfig, 31 | }; 32 | -------------------------------------------------------------------------------- /src/app/bootstrap/bootstrap.provider.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentProviders, importProvidersFrom, makeEnvironmentProviders } from '@angular/core'; 2 | import { FormlyBootstrapModule } from '@ngx-formly/bootstrap'; 3 | import { FormlyModule } from '@ngx-formly/core'; 4 | 5 | export function provideBootstrap(): EnvironmentProviders { 6 | return makeEnvironmentProviders([ 7 | importProvidersFrom([ 8 | FormlyBootstrapModule, 9 | FormlyModule.forRoot({ 10 | validationMessages: [{ name: 'required', message: 'This field is required' }], 11 | }), 12 | ]), 13 | ]); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/material/components/card-wrapper/card-wrapper.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | @if (to.cardTitle) { 4 | {{ to.cardTitle }} 5 | } 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/app/material/components/card-wrapper/card-wrapper.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card'; 3 | import { FieldWrapper } from '@ngx-formly/core'; 4 | 5 | @Component({ 6 | selector: 'app-card-wrapper', 7 | templateUrl: './card-wrapper.component.html', 8 | imports: [MatCard, MatCardHeader, MatCardTitle, MatCardContent], 9 | }) 10 | export class CardWrapperComponent extends FieldWrapper {} 11 | -------------------------------------------------------------------------------- /src/app/material/components/card-wrapper/card-wrapper.provider.ts: -------------------------------------------------------------------------------- 1 | import { importProvidersFrom } from '@angular/core'; 2 | import { FormlyModule } from '@ngx-formly/core'; 3 | 4 | import { CardWrapperComponent } from './card-wrapper.component'; 5 | 6 | export function provideCardWrapper() { 7 | return importProvidersFrom([ 8 | FormlyModule.forChild({ 9 | wrappers: [ 10 | { 11 | name: 'card', 12 | component: CardWrapperComponent, 13 | }, 14 | ], 15 | }), 16 | ]); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/material/components/repeating-section-type/repeating-section-type.component.html: -------------------------------------------------------------------------------- 1 | @if (props.label) { 2 | {{ props.label }} 3 | } 4 | @if (props.description) { 5 |

{{ props.description }}

6 | } 7 | 8 | @for (field of field.fieldGroup; track field; let i = $index) { 9 |
10 | 14 | 21 |
22 | } 23 |
24 | 31 |
32 | -------------------------------------------------------------------------------- /src/app/material/components/repeating-section-type/repeating-section-type.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MatButton, MatIconButton } from '@angular/material/button'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { FieldArrayType, FormlyModule } from '@ngx-formly/core'; 5 | 6 | @Component({ 7 | selector: 'app-repeating-section-type', 8 | templateUrl: './repeating-section-type.component.html', 9 | imports: [FormlyModule, MatIconButton, MatIcon, MatButton], 10 | }) 11 | export class RepeatingSectionTypeComponent extends FieldArrayType {} 12 | -------------------------------------------------------------------------------- /src/app/material/components/repeating-section-type/repeating-section-type.provider.ts: -------------------------------------------------------------------------------- 1 | import { importProvidersFrom } from '@angular/core'; 2 | import { FormlyModule } from '@ngx-formly/core'; 3 | 4 | import { RepeatingSectionTypeComponent } from './repeating-section-type.component'; 5 | 6 | export function provideRepeatingSectionType() { 7 | return importProvidersFrom([ 8 | FormlyModule.forChild({ 9 | types: [ 10 | { 11 | name: 'repeating-section', 12 | component: RepeatingSectionTypeComponent, 13 | }, 14 | ], 15 | }), 16 | ]); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/material/material.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { EditorComponent } from '@sesan07/ngx-formly-editor'; 3 | 4 | @Component({ 5 | selector: 'app-material', 6 | template: ` `, 7 | imports: [EditorComponent], 8 | }) 9 | export class MaterialComponent {} 10 | -------------------------------------------------------------------------------- /src/app/material/material.provider.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentProviders, importProvidersFrom, makeEnvironmentProviders } from '@angular/core'; 2 | import { FormlyModule } from '@ngx-formly/core'; 3 | import { FormlyMaterialModule } from '@ngx-formly/material'; 4 | 5 | import { provideCardWrapper } from './components/card-wrapper/card-wrapper.provider'; 6 | import { provideRepeatingSectionType } from './components/repeating-section-type/repeating-section-type.provider'; 7 | import { ipAsyncValidator, ipValidator, ipValidatorMessage } from './material.utils'; 8 | 9 | export function provideMaterial(): EnvironmentProviders { 10 | return makeEnvironmentProviders([ 11 | provideCardWrapper(), 12 | provideRepeatingSectionType(), 13 | importProvidersFrom([ 14 | FormlyMaterialModule, 15 | FormlyModule.forRoot({ 16 | validators: [ 17 | { name: 'ip', validation: ipValidator }, 18 | { name: 'ipAsync', validation: ipAsyncValidator }, 19 | ], 20 | validationMessages: [ 21 | { name: 'ip', message: ipValidatorMessage }, 22 | { name: 'ipAsync', message: 'This is not a valid IP Address' }, 23 | { name: 'required', message: 'This field is required' }, 24 | ], 25 | }), 26 | ]), 27 | ]); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/material/material.utils.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl } from '@angular/forms'; 2 | import { FormlyFieldConfig } from '@ngx-formly/core'; 3 | import { of } from 'rxjs'; 4 | 5 | const validateIp = (control: AbstractControl, name: string): any => 6 | /(\d{1,3}\.){3}\d{1,3}/.test(control.value) ? null : { [name]: true }; 7 | 8 | export const ipValidator = (control: AbstractControl): any => validateIp(control, 'ip'); 9 | export const ipAsyncValidator = (control: AbstractControl): any => of(validateIp(control, 'ipAsync')); 10 | 11 | export const ipValidatorMessage = (error: any, field: FormlyFieldConfig) => 12 | `"${field.formControl.value}" is not a valid IP Address`; 13 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesan07/formly-editor/ccc97493431b7345b5e59b985b9774034daffecc/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/img/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment: any = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment: any = { 6 | production: false, 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesan07/formly-editor/ccc97493431b7345b5e59b985b9774034daffecc/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Formly Editor 6 | 7 | 11 | 16 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { bootstrapApplication } from '@angular/platform-browser'; 3 | 4 | import { AppComponent } from './app/app.component'; 5 | import { appConfig } from './app/app.config'; 6 | import { environment } from './environments/environment'; 7 | 8 | if (environment.production) { 9 | enableProdMode(); 10 | } 11 | 12 | bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates. 3 | */ 4 | import '@angular/localize/init'; 5 | /** 6 | * This file includes polyfills needed by Angular and is loaded before the app. 7 | * You can add your own extra polyfills to this file. 8 | * 9 | * This file is divided into 2 sections: 10 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 11 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 12 | * file. 13 | * 14 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 15 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 16 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 17 | * 18 | * Learn more in https://angular.io/guide/browser-support 19 | */ 20 | 21 | /*************************************************************************************************** 22 | * BROWSER POLYFILLS 23 | */ 24 | 25 | /** 26 | * By default, zone.js will patch all possible macroTask and DomEvents 27 | * user can disable parts of macroTask/DomEvents patch by setting following flags 28 | * because those flags need to be set before `zone.js` being loaded, and webpack 29 | * will put import in the top of bundle, so user need to create a separate file 30 | * in this directory (for example: zone-flags.ts), and put the following flags 31 | * into that file, and then add the following code before importing zone.js. 32 | * import './zone-flags'; 33 | * 34 | * The flags allowed in zone-flags.ts are listed here. 35 | * 36 | * The following flags will work for all browsers. 37 | * 38 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 39 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 40 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 41 | * 42 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 43 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 44 | * 45 | * (window as any).__Zone_enable_cross_context_check = true; 46 | * 47 | */ 48 | 49 | /*************************************************************************************************** 50 | * Zone JS is required by default for Angular itself. 51 | */ 52 | import 'zone.js'; // Included with Angular CLI. 53 | 54 | /*************************************************************************************************** 55 | * APPLICATION IMPORTS 56 | */ 57 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'bootstrap/scss/bootstrap'; 2 | 3 | // Generated tailwind styles for the editor's styling system (styling tab) 4 | @import './projects/editor/assets/styles/tailwind'; 5 | 6 | // Styles required by the editor 7 | @import './projects/editor/assets/styles/styles'; 8 | 9 | // Angular material theme 10 | @import './theme'; 11 | 12 | html, 13 | body, 14 | app-root { 15 | height: 100%; 16 | } 17 | body { 18 | margin: 0; 19 | font-family: Roboto, 'Helvetica Neue', sans-serif; 20 | } 21 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import { getTestBed } from '@angular/core/testing'; 4 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 5 | import 'zone.js/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 9 | teardown: { destroyAfterEach: false }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/theme.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | html { 4 | color-scheme: light; 5 | @include mat.theme( 6 | ( 7 | color: mat.$cyan-palette, 8 | typography: Roboto, 9 | density: -1, 10 | ) 11 | ); 12 | } 13 | @include mat.form-field-density(-1); 14 | @include mat.form-field-overrides( 15 | ( 16 | filled-label-text-size: var(--mat-sys-body-medium-size), 17 | container-text-size: var(--mat-sys-body-medium-size), 18 | ) 19 | ); 20 | @include mat.select-overrides( 21 | ( 22 | trigger-text-size: var(--mat-sys-body-medium-size), 23 | ) 24 | ); 25 | @include mat.option-overrides( 26 | ( 27 | label-text-size: var(--mat-sys-body-medium-size), 28 | ) 29 | ); 30 | @include mat.expansion-overrides( 31 | ( 32 | header-text-size: var(--mat-sys-body-medium-size), 33 | ) 34 | ); 35 | @include mat.tabs-overrides( 36 | ( 37 | divider-color: var(--mat-sys-outline-variant), 38 | ) 39 | ); 40 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": ["node", "jasmine"] 7 | }, 8 | "files": ["src/main.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "esModuleInterop": true, 8 | "declaration": false, 9 | "experimentalDecorators": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "noImplicitOverride": true, 13 | "target": "ES2022", 14 | "module": "es2020", 15 | "lib": ["es2020", "dom"], 16 | "paths": { 17 | "@sesan07/ngx-formly-editor": ["projects/editor/src/public-api"] 18 | }, 19 | "useDefineForClassFields": false 20 | }, 21 | "angularCompilerOptions": { 22 | "strictTemplates": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "files": ["src/test.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | --------------------------------------------------------------------------------