├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── projects ├── demo │ ├── src │ │ ├── app │ │ │ ├── app.component.html │ │ │ ├── app.component.ts │ │ │ ├── config.ts │ │ │ └── pokemon.service.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ ├── polyfills.ts │ │ └── styles.scss │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.spec.json └── lib │ ├── ng-package.json │ ├── package.json │ ├── schematics │ ├── collection.json │ ├── make-static │ │ ├── index.ts │ │ ├── rules │ │ │ ├── config-rule.ts │ │ │ └── template-rule.ts │ │ ├── schema.json │ │ ├── schema.ts │ │ └── utils │ │ │ └── html-transformer.ts │ └── ng-add │ │ └── index.ts │ ├── src │ ├── conditions │ │ ├── condition.pipe.ts │ │ ├── conditions.service.ts │ │ ├── index.ts │ │ └── public-api.ts │ ├── configurable │ │ ├── configurable.directive.ts │ │ ├── configurable.service.ts │ │ ├── index.ts │ │ └── public-api.ts │ ├── configuration │ │ ├── config.actions.ts │ │ ├── config.model.ts │ │ ├── config.module.ts │ │ ├── config.reducer.ts │ │ ├── config.selectors.ts │ │ ├── config.service.ts │ │ ├── index.ts │ │ └── public-api.ts │ ├── configurator │ │ ├── checkbox │ │ │ └── checkbox.component.ts │ │ ├── color-picker │ │ │ └── color-picker.component.ts │ │ ├── configurator.component.html │ │ ├── configurator.component.scss │ │ ├── configurator.component.ts │ │ ├── configurator.models.ts │ │ ├── editors │ │ │ ├── class-editor.component.ts │ │ │ ├── condition-editor.component.html │ │ │ ├── condition-editor.component.ts │ │ │ ├── flex-editor.component.html │ │ │ ├── flex-editor.component.scss │ │ │ ├── flex-editor.component.ts │ │ │ ├── html-editor.component.ts │ │ │ ├── index.ts │ │ │ └── spacing-editor.component.ts │ │ ├── image-selector │ │ │ └── img-selector.component.ts │ │ ├── index.ts │ │ ├── multi-selector │ │ │ ├── multi-selector.component.html │ │ │ └── multi-selector.component.ts │ │ ├── palette │ │ │ ├── palette.component.html │ │ │ ├── palette.component.scss │ │ │ └── palette.component.ts │ │ ├── public-api.ts │ │ ├── toolbar │ │ │ ├── toolbar.component.html │ │ │ └── toolbar.component.ts │ │ └── tree │ │ │ ├── tree.component.css │ │ │ ├── tree.component.html │ │ │ └── tree.component.ts │ ├── dynamic-views │ │ ├── drag-drop.service.ts │ │ ├── index.ts │ │ ├── item │ │ │ ├── item.component.html │ │ │ └── item.component.ts │ │ ├── public-api.ts │ │ └── zone │ │ │ ├── zone-context.service.ts │ │ │ ├── zone.component.html │ │ │ └── zone.component.ts │ ├── public-api.ts │ ├── svg │ │ └── svg-icons.ts │ ├── uib.module.ts │ └── utils │ │ ├── autocomplete │ │ └── autocomplete.component.ts │ │ ├── directive │ │ ├── model-change.directive.ts │ │ ├── template-name.directive.ts │ │ └── tooltip.directive.ts │ │ ├── index.ts │ │ ├── modal │ │ └── modal.component.ts │ │ ├── public-api.ts │ │ ├── svg-icon │ │ ├── index.ts │ │ ├── public-api.ts │ │ ├── registry.ts │ │ ├── svg-icon.component.ts │ │ ├── svg-icon.module.ts │ │ └── types.ts │ │ ├── toast │ │ ├── toast.component.html │ │ ├── toast.component.ts │ │ └── toast.service.ts │ │ └── typings.ts │ ├── styles │ ├── _mixins.scss │ ├── _selected.scss │ ├── _uib-bootstrap.scss │ ├── _zone.scss │ └── ui-builder.scss │ ├── tsconfig.doc.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.schematics.json │ └── tsconfig.spec.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | matrix: 25 | node-version: [16.x] 26 | 27 | # Steps represent a sequence of tasks that will be executed as part of the job 28 | steps: 29 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 30 | - uses: actions/checkout@v2 31 | 32 | - name: Clean install 33 | run: npm ci --force 34 | 35 | - name: Build lib 36 | run: npm run build:lib 37 | 38 | - name: Build app 39 | run: npm run build demo 40 | -------------------------------------------------------------------------------- /.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 | 16 | # IDEs and editors 17 | /.idea 18 | .project 19 | .classpath 20 | .c9/ 21 | *.launch 22 | .settings/ 23 | *.sublime-workspace 24 | 25 | # IDE - VSCode 26 | .vscode/* 27 | !.vscode/settings.json 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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sinequa 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 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "demo": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | }, 12 | "@schematics/angular:application": { 13 | "strict": true 14 | } 15 | }, 16 | "root": "projects/demo", 17 | "sourceRoot": "projects/demo/src", 18 | "prefix": "app", 19 | "architect": { 20 | "build": { 21 | "builder": "@angular-devkit/build-angular:application", 22 | "options": { 23 | "outputPath": { 24 | "base": "dist/demo" 25 | }, 26 | "index": "projects/demo/src/index.html", 27 | "polyfills": [ 28 | "projects/demo/src/polyfills.ts" 29 | ], 30 | "tsConfig": "projects/demo/tsconfig.json", 31 | "inlineStyleLanguage": "scss", 32 | "stylePreprocessorOptions": { 33 | "includePaths": ["node_modules"] 34 | }, 35 | "assets": [ 36 | "projects/demo/src/favicon.ico", 37 | "projects/demo/src/assets" 38 | ], 39 | "styles": [ 40 | "projects/demo/src/styles.scss" 41 | ], 42 | "scripts": [], 43 | "browser": "projects/demo/src/main.ts" 44 | }, 45 | "configurations": { 46 | "production": { 47 | "budgets": [ 48 | { 49 | "type": "initial", 50 | "maximumWarning": "500kb", 51 | "maximumError": "1mb" 52 | }, 53 | { 54 | "type": "anyComponentStyle", 55 | "maximumWarning": "8kb", 56 | "maximumError": "10kb" 57 | } 58 | ], 59 | "fileReplacements": [ 60 | { 61 | "replace": "projects/demo/src/environments/environment.ts", 62 | "with": "projects/demo/src/environments/environment.prod.ts" 63 | } 64 | ], 65 | "outputHashing": "all" 66 | }, 67 | "development": { 68 | "optimization": false, 69 | "extractLicenses": false, 70 | "sourceMap": true, 71 | "namedChunks": true 72 | } 73 | }, 74 | "defaultConfiguration": "production" 75 | }, 76 | "serve": { 77 | "builder": "@angular-devkit/build-angular:dev-server", 78 | "configurations": { 79 | "production": { 80 | "buildTarget": "demo:build:production" 81 | }, 82 | "development": { 83 | "buildTarget": "demo:build:development" 84 | } 85 | }, 86 | "defaultConfiguration": "development" 87 | }, 88 | "extract-i18n": { 89 | "builder": "@angular-devkit/build-angular:extract-i18n", 90 | "options": { 91 | "buildTarget": "demo:build" 92 | } 93 | } 94 | } 95 | }, 96 | "lib": { 97 | "projectType": "library", 98 | "root": "projects/lib", 99 | "sourceRoot": "projects/lib/src", 100 | "prefix": "lib", 101 | "architect": { 102 | "build": { 103 | "builder": "@angular-devkit/build-angular:ng-packagr", 104 | "options": { 105 | "project": "projects/lib/ng-package.json" 106 | }, 107 | "configurations": { 108 | "production": { 109 | "tsConfig": "projects/lib/tsconfig.lib.prod.json" 110 | }, 111 | "development": { 112 | "tsConfig": "projects/lib/tsconfig.lib.json" 113 | } 114 | }, 115 | "defaultConfiguration": "production" 116 | } 117 | } 118 | } 119 | 120 | }, 121 | "cli": { 122 | "analytics": false 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-ui-builder", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "tsc": "tsc", 7 | "start": "ng serve", 8 | "build": "ng build", 9 | "build:lib": "ng build lib", 10 | "postbuild:lib": "copyfiles README.md dist/lib/ && cd projects/lib && npm run build", 11 | "watch": "ng build --watch --configuration development" 12 | }, 13 | "private": false, 14 | "dependencies": { 15 | "@angular/animations": "^18.2.13", 16 | "@angular/common": "^18.2.13", 17 | "@angular/compiler": "^18.2.13", 18 | "@angular/core": "^18.2.13", 19 | "@angular/forms": "^18.2.13", 20 | "@angular/platform-browser": "^18.2.13", 21 | "@angular/platform-browser-dynamic": "^18.2.13", 22 | "@angular/router": "^18.2.13", 23 | "@ngrx/store": "^18.1.1", 24 | "@popperjs/core": "^2.11.5", 25 | "bootstrap": "^5.2.0", 26 | "immer": "^9.0.16", 27 | "ngrx-wieder": "^13.0.0", 28 | "ngx-drag-drop": "^18.0.2", 29 | "rxjs": "~7.5.0", 30 | "tslib": "^2.3.1", 31 | "zone.js": "~0.14.10" 32 | }, 33 | "overrides": { 34 | "esbuild": "0.25.0" 35 | }, 36 | "devDependencies": { 37 | "@angular-devkit/build-angular": "^18.2.14", 38 | "@angular/cli": "~18.2.14", 39 | "@angular/compiler-cli": "^18.2.13", 40 | "@angular/localize": "^18.2.13", 41 | "@schematics/angular": "^18.2.14", 42 | "@types/bootstrap": "^5.2.0", 43 | "@types/node": "^12.11.1", 44 | "copyfiles": "^2.4.1", 45 | "html-prettify": "1.0.7", 46 | "htmlparser": "^1.7.7", 47 | "ng-packagr": "^18.2.1", 48 | "sanitize-html": "2.12.1", 49 | "typescript": "~5.4.5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

{{ config.title }} - DEMO

6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 |
22 | 23 |
24 | 25 |
26 | 27 | 28 | 29 |
30 |

Filters

31 | 32 | 33 |

{{ config.field | titlecase }}: {{ this[config.field] }}

34 | 35 |
36 | 37 | 38 |

Abilities:

39 |
40 | 41 |
42 |
43 |
44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |

{{ data?.name | titlecase }} #{{ data?.id }}

56 |
57 | {{data?.description}} 58 |
59 | 60 |
61 | 62 | {{ config.field }}: 63 | 64 | 65 |
66 | 67 |
68 | 69 |
70 | 71 | 72 | 73 |
74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 92 | 93 | 94 | 95 |
96 | 97 | 103 |
104 |
105 | 106 | 107 |
108 |
109 | 110 |
111 | 112 | 113 |
114 | 115 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ConfigurableService, ConfigService, ZoneComponent, ToolbarComponent, ConfiguratorComponent, ToastComponent, TemplateNameDirective, NgModelChangeDebouncedDirective, TooltipDirective } from '@sinequa/ngx-ui-builder'; 3 | import { PokemonService } from "./pokemon.service"; 4 | import { defaultConfig } from "./config"; 5 | import { CommonModule } from '@angular/common'; 6 | import { FormsModule } from '@angular/forms'; 7 | 8 | @Component({ 9 | selector: 'app-root', 10 | standalone: true, 11 | imports: [ 12 | CommonModule, 13 | FormsModule, 14 | ZoneComponent, 15 | ToolbarComponent, 16 | ConfiguratorComponent, 17 | ToastComponent, 18 | TemplateNameDirective, 19 | NgModelChangeDebouncedDirective, 20 | TooltipDirective 21 | ], 22 | templateUrl: './app.component.html' 23 | }) 24 | export class AppComponent implements OnInit { 25 | 26 | // Pokestore state 27 | allAbilities: string[] = []; 28 | searchText?: string; 29 | abilities: string[] = []; 30 | weight?: number; 31 | experience?: number; 32 | 33 | constructor( 34 | public configService: ConfigService, 35 | public configurableService: ConfigurableService, 36 | public pokemonService: PokemonService 37 | ) {} 38 | 39 | ngOnInit() { 40 | 41 | this.allAbilities = this.pokemonService.getAbilities(); 42 | 43 | // Initial state of the UI builder 44 | this.configService.init(defaultConfig); 45 | } 46 | 47 | // Pokestore functionalities 48 | 49 | search() { 50 | this.pokemonService.searchPokemons(this.searchText, this.abilities, this.weight, this.experience); 51 | } 52 | 53 | selectAbility(a: string) { 54 | this.abilities.push(a); 55 | this.search(); 56 | return false; 57 | } 58 | 59 | clear() { 60 | this.searchText = undefined; 61 | this.abilities = []; 62 | this.experience = undefined; 63 | this.weight = undefined; 64 | this.pokemonService.Reset(); 65 | } 66 | 67 | // Utilities 68 | 69 | asArray(value: any) { 70 | return Array.isArray(value)? value : [value]; 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /projects/demo/src/app/config.ts: -------------------------------------------------------------------------------- 1 | import { ComponentConfig } from "@sinequa/ngx-ui-builder"; 2 | 3 | export const defaultConfig:ComponentConfig[] = [ 4 | { 5 | id: 'header', 6 | type: '_container', 7 | items: ['title', 'searchform'], 8 | classes: 'flex-column' 9 | }, 10 | { 11 | id: 'title', 12 | type: 'title', 13 | title: 'Pokestore' 14 | }, 15 | { 16 | id: 'searchform', 17 | type: '_container', 18 | items: ['searchbar','applied-filters','clear-filters'], 19 | classes: 'flex-row' 20 | }, 21 | { 22 | id: "searchbar", 23 | type: "searchbar" 24 | }, 25 | { 26 | id: 'results', 27 | type: '_container', 28 | items: ['image','metas'], 29 | classes: 'result-item flex-row mb-3 w-100' 30 | }, 31 | { 32 | id: 'metas', 33 | type: '_container', 34 | items: ['pokename', 'description', 'ability', 'weight', 'experience'], 35 | classes: 'flex-column' 36 | }, 37 | { 38 | id: 'ability', 39 | type: 'metadata', 40 | field: 'abilities', 41 | }, 42 | { 43 | id: 'weight', 44 | type: 'metadata', 45 | field: 'weight', 46 | }, 47 | { 48 | id: 'experience', 49 | type: 'metadata', 50 | field: 'experience', 51 | }, 52 | 53 | { 54 | id: 'filters', 55 | type: '_container', 56 | items: ['abilities', 'weightFilter', 'xpFilter'], 57 | classes: 'flex-column' 58 | }, 59 | { 60 | id: 'weightFilter', 61 | type: 'range', 62 | field: 'weight', 63 | }, 64 | { 65 | id: 'xpFilter', 66 | type: 'range', 67 | field: 'experience', 68 | } 69 | ]; -------------------------------------------------------------------------------- /projects/demo/src/app/pokemon.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from "@angular/common/http"; 2 | import { inject, Injectable } from "@angular/core"; 3 | import { BehaviorSubject, firstValueFrom } from "rxjs"; 4 | 5 | export interface Pokemon { 6 | name: string; 7 | description: string; 8 | id: number; 9 | abilities: string[]; 10 | weight: number; 11 | experience: number; 12 | } 13 | 14 | 15 | @Injectable({providedIn: 'root'}) 16 | export class PokemonService { 17 | 18 | api:Record; 19 | 20 | pokemons: Pokemon[] = [ 21 | { 22 | name: 'slowpoke', 23 | description: 24 | "Slowpoke (Japanese: ヤドン Yadon) is a dual-type Water/Psychic Pokémon introduced in Generation I. It evolves into Slowbro starting at level 37 or Slowking when traded while holding a King's Rock. In Galar, Slowpoke has a pure Psychic-type regional form, introduced in Pokémon Sword and Shield's 1.1.0 patch. It evolves into Galarian Slowbro when exposed to a Galarica Cuff or Galarian Slowking when exposed to a Galarica Wreath.", 25 | id: 79, 26 | abilities: ['oblivious', 'own-tempo', 'regenerator'], 27 | weight: 360, 28 | experience: 63, 29 | }, 30 | { 31 | name: 'zacian-hero', 32 | description: 33 | 'Zacian (Japanese: ザシアン Zacian) is a Fairy-type Legendary Pokémon introduced in Generation VIII. While it is not known to evolve into or from any other Pokémon, Zacian has a second form activated by giving it a Rusted Sword to hold. Its original form, Hero of Many Battles, will then become the Fairy/Steel-type Crowned Sword. Zacian is the game mascot of Pokémon Sword, appearing on the boxart in its Crowned Sword form. It is a member of the Hero duo with Zamazenta.', 34 | id: 888, 35 | abilities: ['intrepid-sword'], 36 | weight: 1100, 37 | experience: 335, 38 | }, 39 | { 40 | name: 'clefairy', 41 | description: 42 | 'Clefairy (Japanese: ピッピ Pippi) is a Fairy-type Pokémon introduced in Generation I. Prior to Generation VI, it was a Normal-type Pokémon. It evolves from Cleffa when leveled up with high friendship and evolves into Clefable when exposed to a Moon Stone.', 43 | id: 35, 44 | abilities: ['cute-charm'], 45 | weight: 75, 46 | experience: 113, 47 | }, 48 | { 49 | name: 'pikachu', 50 | description: 51 | 'Pikachu (Japanese: ピカチュウ Pikachu) is an Electric-type Pokémon introduced in Generation I. It evolves from Pichu when leveled up with high friendship and evolves into Raichu when exposed to a Thunder Stone. In Alola, Pikachu will evolve into Alolan Raichu when exposed to a Thunder Stone.', 52 | id: 25, 53 | abilities: ['static', 'lightning-rod'], 54 | weight: 60, 55 | experience: 112, 56 | }, 57 | { 58 | name: 'bulbasaur', 59 | description: 60 | 'Bulbasaur (Japanese: フシギダネ Fushigidane) is a dual-type Grass/Poison Pokémon introduced in Generation I. It evolves into Ivysaur starting at level 16, which evolves into Venusaur starting at level 32. Along with Charmander and Squirtle, Bulbasaur is one of three starter Pokémon of Kanto available at the beginning of Pokémon Red, Green, Blue, FireRed, and LeafGreen.', 61 | id: 1, 62 | abilities: ['overgrow', 'chlorophyll'], 63 | weight: 69, 64 | experience: 64, 65 | }, 66 | { 67 | name: 'charmander', 68 | description: 69 | 'Charmander (Japanese: ヒトカゲ Hitokage) is a Fire-type Pokémon introduced in Generation I. It evolves into Charmeleon starting at level 16, which evolves into Charizard starting at level 36. Along with Bulbasaur and Squirtle, Charmander is one of three starter Pokémon of Kanto available at the beginning of Pokémon Red, Green, Blue, FireRed, and LeafGreen.', 70 | id: 4, 71 | abilities: ['blaze', 'solar-power'], 72 | weight: 85, 73 | experience: 62, 74 | }, 75 | { 76 | name: 'sandshrew', 77 | description: 78 | 'Sandshrew (Japanese: サンド Sand) is a Ground-type Pokémon introduced in Generation I. It evolves into Sandslash starting at level 22. In Alola, Sandshrew has a dual-type Ice/Steel regional form. It evolves into Alolan Sandslash when exposed to an Ice Stone.', 79 | id: 27, 80 | abilities: ['sand-veil', 'sand-rush'], 81 | weight: 120, 82 | experience: 60, 83 | }, 84 | { 85 | name: 'wartortle', 86 | description: 87 | 'Wartortle (Japanese: カメール Kameil) is a Water-type Pokémon introduced in Generation I. It evolves from Squirtle starting at level 16 and evolves into Blastoise starting at level 36.', 88 | id: 142, 89 | abilities: ['torrent', 'rain-dish'], 90 | weight: 225, 91 | experience: 142, 92 | }, 93 | { 94 | name: 'poliwhirl', 95 | description: 96 | "Poliwhirl (Japanese: ニョロゾ Nyorozo) is a Water-type Pokémon introduced in Generation I. It evolves from Poliwag starting at level 25. It evolves into Poliwrath when exposed to a Water Stone or Politoed when traded while holding a King's Rock.", 97 | id: 61, 98 | abilities: ['water-absorb', 'damp', 'swift-swim'], 99 | weight: 200, 100 | experience: 135, 101 | }, 102 | { 103 | name: 'vileplume', 104 | description: 105 | "Vileplume (Japanese: ラフレシア Ruffresia) is a dual-type Grass/Poison Pokémon introduced in Generation I. It evolves from Gloom when exposed to a Leaf Stone. It is one of Oddish's final forms, the other being Bellossom.", 106 | id: 45, 107 | abilities: ['chlorophyll', 'effect-spore'], 108 | weight: 186, 109 | experience: 221, 110 | }, 111 | ]; 112 | 113 | pokemons$ = new BehaviorSubject([]); 114 | 115 | httpClient = inject(HttpClient); 116 | 117 | constructor() { 118 | // custom API Proxy 119 | this.api = this.createApi("https://pokeapi.co/api/v2"); 120 | this.getPokemon(); 121 | } 122 | 123 | async getPokemon() { 124 | // retrieve 100 pokemon, and for each retrieve informations 125 | const pokemon = await this.api.pokemon(); 126 | const pokemonPromises = pokemon.results.map(p => this.createPokemon(p)); 127 | 128 | // when all pokemon are created emit signal 129 | Promise.all(pokemonPromises).then(values => { 130 | this.pokemons = values; 131 | this.pokemons$.next(values) 132 | }); 133 | } 134 | 135 | async createPokemon(p): Promise { 136 | // call: https://pokeapi.co/api/v2/pokemon/:name 137 | const poke = await this.api.pokemon(p.name); 138 | 139 | // call: https://pokeapi.co/api/v2/pokemon-species/:id 140 | const desc = await this.api['pokemon-species'](poke.id); 141 | 142 | // create a Pokemon object 143 | return ({ 144 | id: poke.id, 145 | name: p.name, 146 | description: desc.flavor_text_entries.filter(({ language, version }) => language.name === "en" && version.name === "x") 147 | .map(({ flavor_text }) => flavor_text) 148 | .join() 149 | .replaceAll(" ", " "), 150 | abilities: poke.abilities.map(({ability}) => ability.name), 151 | weight: poke.weight, 152 | experience: poke.base_experience 153 | }); 154 | } 155 | 156 | private createApi = (url) => { 157 | const http = this.httpClient; 158 | return new Proxy({}, { 159 | get(target, key:string) { 160 | return async function (id = "") { 161 | const response = await firstValueFrom(http.get(`${url}/${key}/${id}?limit=100`)); 162 | if (response) { 163 | return response; 164 | } 165 | return Promise.resolve({error: "Malformed Request"}) 166 | } 167 | } 168 | }) 169 | } 170 | 171 | searchPokemons(search = "", abilities: string[] = [], weight?: number, experience?: number) { 172 | this.pokemons$.next(this.pokemons.filter(p => { 173 | if (abilities.length && !p.abilities.find(a => abilities.includes(a))) 174 | return false; 175 | if (weight && p.weight > weight) return false; 176 | if (experience && p.experience > experience) 177 | return false; 178 | if (search && !p.name.includes(search)) return false; 179 | return true; 180 | })); 181 | } 182 | 183 | Reset() { 184 | this.pokemons$.next(this.pokemons); 185 | } 186 | 187 | getAbilities(): string[] { 188 | return this.pokemons 189 | .map((p) => p.abilities) 190 | .reduce((v1, v2) => v1.concat(v2), []); 191 | } 192 | 193 | } 194 | -------------------------------------------------------------------------------- /projects/demo/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinequa/ngx-ui-builder/b4bdcb6e4ddf8696e22b452908960b8a73590269/projects/demo/src/assets/.gitkeep -------------------------------------------------------------------------------- /projects/demo/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /projects/demo/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 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 | -------------------------------------------------------------------------------- /projects/demo/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinequa/ngx-ui-builder/b4bdcb6e4ddf8696e22b452908960b8a73590269/projects/demo/src/favicon.ico -------------------------------------------------------------------------------- /projects/demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pokestore 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /projects/demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode, importProvidersFrom } from '@angular/core'; 2 | import { bootstrapApplication } from '@angular/platform-browser'; 3 | 4 | import { environment } from './environments/environment'; 5 | import { AppComponent } from "./app/app.component"; 6 | import { StoreModule } from '@ngrx/store'; 7 | import { ConfigModule, SvgIconsModule, icons } from '@sinequa/ngx-ui-builder'; 8 | import { provideHttpClient } from '@angular/common/http'; 9 | 10 | if (environment.production) { 11 | enableProdMode(); 12 | } 13 | 14 | bootstrapApplication(AppComponent, { 15 | providers: [ 16 | provideHttpClient(), 17 | importProvidersFrom([ 18 | SvgIconsModule.forRoot({icons: icons}), 19 | StoreModule.forRoot({}), 20 | ConfigModule 21 | ]) 22 | ] 23 | }) 24 | -------------------------------------------------------------------------------- /projects/demo/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 | * IE11 requires the following for NgClass support on SVG elements 27 | */ 28 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 29 | 30 | /** 31 | * Web Animations `@angular/platform-browser/animations` 32 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 33 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 34 | */ 35 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 36 | 37 | /** 38 | * By default, zone.js will patch all possible macroTask and DomEvents 39 | * user can disable parts of macroTask/DomEvents patch by setting following flags 40 | * because those flags need to be set before `zone.js` being loaded, and webpack 41 | * will put import in the top of bundle, so user need to create a separate file 42 | * in this directory (for example: zone-flags.ts), and put the following flags 43 | * into that file, and then add the following code before importing zone.js. 44 | * import './zone-flags'; 45 | * 46 | * The flags allowed in zone-flags.ts are listed here. 47 | * 48 | * The following flags will work for all browsers. 49 | * 50 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 51 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 52 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 53 | * 54 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 55 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 56 | * 57 | * (window as any).__Zone_enable_cross_context_check = true; 58 | * 59 | */ 60 | 61 | /*************************************************************************************************** 62 | * Zone JS is required by default for Angular itself. 63 | */ 64 | import 'zone.js'; // Included with Angular CLI. 65 | 66 | 67 | /*************************************************************************************************** 68 | * APPLICATION IMPORTS 69 | */ 70 | -------------------------------------------------------------------------------- /projects/demo/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* bootstrap's utilities only */ 2 | @import "bootstrap/scss/mixins/banner"; 3 | @include bsBanner(Utilities); 4 | @import "bootstrap/scss/functions"; 5 | @import "bootstrap/scss/variables"; 6 | @import "bootstrap/scss/maps"; 7 | @import "bootstrap/scss/mixins"; 8 | @import "bootstrap/scss/utilities"; 9 | @import "bootstrap/scss/root"; 10 | @import "bootstrap/scss/helpers"; 11 | @import "bootstrap/scss/utilities/api"; 12 | 13 | /* specific import used by the ui builder lib */ 14 | @import "../../lib/styles/ui-builder"; 15 | 16 | body { 17 | font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 18 | } 19 | 20 | .filters { 21 | h3, h4 { 22 | margin: 0; 23 | } 24 | h4 { 25 | margin-top: 1em; 26 | } 27 | } 28 | 29 | .main { 30 | display: flex; 31 | max-width: 1000px; 32 | .result-item:hover { 33 | background: aliceblue; 34 | } 35 | } 36 | 37 | .metadata { 38 | color: #212529; 39 | background-color: #e3e6e9; 40 | display: inline-block; 41 | padding: 0.25em 0.4em; 42 | margin: 0 0.25em 0.25em 0.25em; 43 | font-size: 85%; 44 | font-weight: 700; 45 | line-height: 1; 46 | text-align: center; 47 | white-space: nowrap; 48 | vertical-align: baseline; 49 | padding-right: 0.6em; 50 | padding-left: 0.6em; 51 | border-radius: 10rem; 52 | text-decoration: none; 53 | } 54 | 55 | a.metadata:hover { 56 | background-color: #cfd5db; 57 | } 58 | -------------------------------------------------------------------------------- /projects/demo/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": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /projects/demo/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/app", 6 | "types": [], 7 | "paths": { 8 | "@sinequa/ngx-ui-builder": ["./projects/lib/src/public-api"], 9 | "@sinequa/ngx-ui-builder/*": ["./projects/lib/src/*"] 10 | } 11 | }, 12 | "files": [ 13 | "src/main.ts", 14 | "src/polyfills.ts" 15 | ], 16 | "include": [ 17 | "src/**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /projects/demo/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": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /projects/lib/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/lib", 4 | "assets": [ 5 | "styles/*.scss" 6 | ], 7 | "lib": { 8 | "entryFile": "src/public-api.ts" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sinequa/ngx-ui-builder", 3 | "version": "1.1.0", 4 | "author": "Sinequa", 5 | "repository": "github:sinequa/ngx-ui-builder", 6 | "homepage": "https://sinequa.github.io/ngx-ui-builder/", 7 | "description": "An Angular library for creating no-code tools & applications", 8 | "keywords": ["no code", "angular", "angular2", "sinequa", "drag and drop", "point and click", "ui builder", "configurable"], 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "tsc -p tsconfig.schematics.json", 12 | "postbuild": "copyfiles schematics/*/schema.json schematics/*/files/** schematics/collection.json ../../dist/lib/" 13 | }, 14 | "schematics": "./schematics/collection.json", 15 | "ng-add": { 16 | "save": "dependencies" 17 | }, 18 | "peerDependencies": { 19 | "@angular/common": "^18.0.0", 20 | "@angular/core": "^18.0.0", 21 | "@ngrx/store": "^18.1.1", 22 | "@popperjs/core": "^2.11.5", 23 | "bootstrap": "^5.2.0", 24 | "immer": "^9.0.16", 25 | "ngrx-wieder": "^13.0.0", 26 | "ngx-drag-drop": "^18.0.0" 27 | }, 28 | "dependencies": { 29 | "tslib": "^2.3.0" 30 | }, 31 | "devDependencies": { 32 | "@types/bootstrap": "^5.1.6", 33 | "@angular/localize": "^14.1.0", 34 | "copyfiles": "file:../../node_modules/copyfiles", 35 | "typescript": "file:../../node_modules/typescript" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /projects/lib/schematics/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", 3 | "schematics": { 4 | "ng-add": { 5 | "description": "Add ngx-ui-builder to the project", 6 | "factory": "./ng-add/index#ngAdd" 7 | }, 8 | "make-static": { 9 | "description": "Turn a UI Builder application into a static application", 10 | "factory": "./make-static/index#makeStatic", 11 | "schema": "./make-static/schema.json" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /projects/lib/schematics/make-static/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Rule, Tree, SchematicContext, chain 3 | } from '@angular-devkit/schematics'; 4 | 5 | import { MakeStaticOptions } from "./schema"; 6 | import { updateHTML } from './rules/template-rule'; 7 | import { addConfigObjectRule } from './rules/config-rule'; 8 | 9 | // Entry point of the make-static schematic 10 | export function makeStatic(options: MakeStaticOptions): Rule { 11 | return (tree: Tree, context: SchematicContext) => { 12 | const rule = chain([ 13 | updateHTML(options), 14 | addConfigObjectRule(options) 15 | ]) 16 | return rule(tree, context); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /projects/lib/schematics/make-static/rules/config-rule.ts: -------------------------------------------------------------------------------- 1 | import { Tree, Rule, SchematicsException } from "@angular-devkit/schematics"; 2 | import ts, {Expression, factory} from "typescript"; 3 | import { MakeStaticOptions } from "../schema"; 4 | 5 | /** 6 | * `LiteralValueType` is a type alias that represents the possible types of 7 | * literal values in TypeScript, such as strings, numbers, and booleans. It is 8 | * used in the `createProperty` and `createValue` functions to ensure that the 9 | * correct type of value is created for a given property assignment. 10 | */ 11 | type LiteralValueType = string | number | boolean | []; 12 | 13 | let CONFIG_IDENTIFIER = 'GLOBAL_CONFIG'; 14 | 15 | /** 16 | * This variable is later used to store 17 | * the configuration data read from a JSON file and to update an existing 18 | * TypeScript object literal expression or create a new one. 19 | */ 20 | let configuration = []; 21 | 22 | /** 23 | * This function reads a configuration file and updates a TypeScript source file 24 | * with a custom transformer or creates a new config object literal if it doesn't 25 | * exist. 26 | * @param options - An object containing options for the function, including: 27 | * @returns A function that takes in an options object and returns a Rule. 28 | */ 29 | export function addConfigObjectRule(options: MakeStaticOptions): Rule { 30 | return async (tree: Tree) => { 31 | 32 | // exit when 'config-path' or 'config' flag are not provided 33 | if (!options?.configPath) return; 34 | if (!options?.config) return; 35 | 36 | CONFIG_IDENTIFIER = options.configIdentifier || CONFIG_IDENTIFIER; 37 | 38 | // first read the configuration json file 39 | const configFileContent = tree.read(options.config); 40 | configuration = JSON.parse(configFileContent?.toString() || ''); 41 | if (!Array.isArray(configuration)) { 42 | throw new SchematicsException(`Expected an array of configuration objects in ${options.config}: ${configuration}`); 43 | } 44 | 45 | // create ts source file 46 | const code = tree.read(options.configPath)?.toString("utf-8"); 47 | const sourceFile = ts.createSourceFile(options.configPath, code || "", ts.ScriptTarget.Latest, true) 48 | 49 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 50 | let content: string; 51 | 52 | // first check if config exists 53 | let found = visit(sourceFile, CONFIG_IDENTIFIER); 54 | if (found) { 55 | // transform ts file with custom transformer 56 | const transformedSourceFile = ts.transform(sourceFile, [updateExistingConfigObject]).transformed[0]; 57 | content = printer.printFile(transformedSourceFile); 58 | 59 | } else { 60 | const c = createDefaultConfigObject(); 61 | content = printer.printFile(sourceFile); 62 | content +=printer.printNode(ts.EmitHint.Unspecified, c, sourceFile); 63 | } 64 | tree.overwrite(options.configPath, content); 65 | }; 66 | } 67 | 68 | 69 | 70 | /** 71 | * This function updates an existing TypeScript configuration object with 72 | * additional properties. 73 | * @param context - The context parameter is an object that provides access to the 74 | * TypeScript compiler's API, including the factory object used to create new AST 75 | * nodes. It is used to generate new AST nodes that will be used to update the 76 | * existing configuration object. 77 | * @returns The function `updateExistingConfigObject` returns a higher-order 78 | * function that takes a TypeScript AST node as an argument and returns a 79 | * transformed version of that node. The transformed node will have additional 80 | * configuration objects appended to an array literal expression if the parent node 81 | * is a variable declaration with a specific identifier. 82 | */ 83 | const updateExistingConfigObject = (context) => (root) => { 84 | function visit(node: ts.Node): ts.VisitResult { 85 | if (ts.isVariableDeclaration(node)) { 86 | if (node.name.getText() === CONFIG_IDENTIFIER && node.type === undefined) { 87 | return factory.updateVariableDeclaration(node, 88 | factory.createIdentifier(CONFIG_IDENTIFIER), 89 | undefined, 90 | factory.createArrayTypeNode(factory.createTypeReferenceNode( 91 | factory.createIdentifier("ComponentConfig"), 92 | undefined 93 | )), 94 | factory.createArrayLiteralExpression( 95 | [...configuration.map(c => ts.factory.createObjectLiteralExpression([ 96 | ...Object.keys(c).map(k => createProperty(k, c[k])) 97 | ]))], 98 | false 99 | ) 100 | ) 101 | } 102 | } 103 | if (ts.isArrayLiteralExpression(node)) { 104 | const { factory } = context; 105 | const parent: ts.VariableDeclaration = node.parent as ts.VariableDeclaration; 106 | if (parent.name && parent.name.getText() === CONFIG_IDENTIFIER) { 107 | return factory.updateArrayLiteralExpression(node, [ 108 | ...node.elements, 109 | ...configuration.map(c => ts.factory.createObjectLiteralExpression([ 110 | ...Object.keys(c).map(k => createProperty(k, c[k])) 111 | ])), 112 | ]) 113 | } 114 | } 115 | return ts.visitEachChild(node, visit, context); 116 | } 117 | return ts.visitNode(root, visit); 118 | } 119 | 120 | /** 121 | * The function visits each node in a TypeScript AST and checks if it is an array 122 | * literal expression with a specific identifier. 123 | * @param node - A TypeScript Node object representing a node in the abstract 124 | * syntax tree (AST) of a TypeScript program. 125 | * @param {string} identifier - The identifier parameter is a string that 126 | * represents the name of a variable that we are searching for in the TypeScript 127 | * AST (Abstract Syntax Tree). 128 | * @returns The function does not have a return statement, so it will return 129 | * `undefined` by default. 130 | */ 131 | function visit(node: ts.Node, identifier: string) { 132 | if (ts.isVariableDeclaration(node)) { 133 | if (node.name.getText() === identifier) { 134 | return true; 135 | } 136 | } 137 | return node.forEachChild((child) => visit(child, identifier)); 138 | } 139 | 140 | 141 | /** 142 | * This function creates a default configuration object in TypeScript. 143 | * @returns a TypeScript AST (Abstract Syntax Tree) node that represents a variable 144 | * statement. Specifically, it is creating a default configuration object by using 145 | * the `factory` object to create a `VariableStatement` node that exports a `const` 146 | * variable declaration with an array literal expression containing object literal 147 | * expressions for each configuration object in the `configuration` array. 148 | */ 149 | function createDefaultConfigObject() { 150 | return factory.createVariableStatement( 151 | [factory.createToken(ts.SyntaxKind.ExportKeyword)], 152 | factory.createVariableDeclarationList( 153 | [factory.createVariableDeclaration( 154 | factory.createIdentifier(CONFIG_IDENTIFIER), 155 | undefined, 156 | factory.createArrayTypeNode(factory.createTypeReferenceNode( 157 | factory.createIdentifier("ComponentConfig"), 158 | undefined 159 | )), 160 | factory.createArrayLiteralExpression( 161 | [...configuration.map(c => ts.factory.createObjectLiteralExpression([ 162 | ...Object.keys(c).map(k => createProperty(k, c[k])) 163 | ]))], 164 | false 165 | ) 166 | )], 167 | ts.NodeFlags.Const 168 | ) 169 | ) 170 | } 171 | 172 | /** 173 | * The function creates a property assignment with a given identifier and value. 174 | * @param {string} identifier - A string representing the name of the property to 175 | * be created. 176 | * @param {LiteralValueType} value - LiteralValueType is a type that represents a 177 | * literal value in TypeScript, such as a string, number, boolean, null, or 178 | * undefined. The `value` parameter in the `createProperty` function is expected to 179 | * be a value of this type. 180 | * @returns The function `createProperty` is returning a property assignment node 181 | * created using the TypeScript factory function 182 | * `factory.createPropertyAssignment`. The property assignment node consists of an 183 | * identifier node created using `factory.createIdentifier` and a value node 184 | * created using the `createValue` function. 185 | */ 186 | function createProperty(identifier: string, value: LiteralValueType) { 187 | return factory.createPropertyAssignment( 188 | factory.createIdentifier(identifier), 189 | createValue(value) 190 | ) 191 | } 192 | /** 193 | * The function creates a TypeScript value based on the type of the input value. 194 | * @param {LiteralValueType} value - The value parameter is of type 195 | * LiteralValueType, which means it can be a number, boolean, string, null, or 196 | * undefined. 197 | * @returns The function `createValue` takes in a parameter `value` of type 198 | * `LiteralValueType` and returns a TypeScript AST node based on the type of the 199 | * input value. If the input value is a number, it returns a numeric literal node 200 | * using the TypeScript factory method `createNumericLiteral()`. If the input value 201 | * is a boolean, it returns a boolean literal node using the TypeScript factory 202 | * method `create 203 | */ 204 | function createValue(value: LiteralValueType): Expression { 205 | switch (typeof(value)) { 206 | case "number": 207 | return factory.createNumericLiteral(value); 208 | case "boolean": 209 | return value ? factory.createTrue() : factory.createFalse() 210 | case "object": 211 | if (Array.isArray(value)) { 212 | return factory.createArrayLiteralExpression([ 213 | ...value.map(v => createValue(v)) 214 | ]) 215 | } 216 | return factory.createObjectLiteralExpression( 217 | Object.keys(value).map(k => createProperty(k, value[k])) 218 | ) 219 | 220 | default: 221 | return factory.createStringLiteral(String(value)); 222 | } 223 | } -------------------------------------------------------------------------------- /projects/lib/schematics/make-static/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "SchematicsMakeStatic", 4 | "title": "Make Static Schema", 5 | "type": "object", 6 | "properties": { 7 | "config": { 8 | "description": "The path to the configuration (.json) of the application.", 9 | "type": "string", 10 | "format": "path" 11 | }, 12 | "configPath": { 13 | "description": "The path to the typescript configuration of the application (e.g: path/to/file/config.ts)", 14 | "type": "string", 15 | "format": "path" 16 | }, 17 | "configIdentifier": { 18 | "description": "The identifier name used to store de configuration", 19 | "type": "string", 20 | "default": "" 21 | }, 22 | "override": { 23 | "description": "If true, will override the source code of the HTML templated rather than generating alternate .static.html files.", 24 | "type": "boolean", 25 | "default": false 26 | }, 27 | "updateTemplateUrls": { 28 | "description": "If true (and if 'override' is false), update the component .ts files to use the .static.html templates", 29 | "type": "boolean", 30 | "default": true 31 | }, 32 | "commentScssImport": { 33 | "description": "If true, comment out the import of the UI Builder stylesheets in the project's stylesheets", 34 | "type": "boolean", 35 | "default": true 36 | }, 37 | "appModuleDependencies": { 38 | "description": "Path to a JSON file mapping a module's name to a list of component types (eg. {PokemonModule: ['pokemon-image', 'pokemon-name']})", 39 | "type": "string", 40 | "format": "path" 41 | }, 42 | "createBase64Images": { 43 | "description": "Detect when the configuration contains images encoded as base64 strings and transform them as files in the assets folder", 44 | "type": "boolean", 45 | "default": true 46 | }, 47 | "project": { 48 | "type": "string", 49 | "description": "The name of the project.", 50 | "$default": { 51 | "$source": "projectName" 52 | } 53 | } 54 | }, 55 | "required": [ 56 | "config" 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /projects/lib/schematics/make-static/schema.ts: -------------------------------------------------------------------------------- 1 | export interface MakeStaticOptions { 2 | /** 3 | * The path to the json config file. 4 | */ 5 | config: string; 6 | 7 | /** 8 | * The path to the ts configuration file (usually config.ts). 9 | * 10 | * @example 11 | * --config-path=path/to/file/config.ts 12 | */ 13 | configPath: string; 14 | 15 | /** 16 | * The configuration identifier to use (e.g: CONFIG) 17 | * 18 | * @example 19 | * --config-identifier=CONFIG 20 | * 21 | * // This will produce this typescript code 22 | * export const CONFIG = {}; 23 | */ 24 | configIdentifier: string; 25 | 26 | /** 27 | * If true, will override the source code of the HTML templates rather than generating .static.html files 28 | */ 29 | override: boolean; 30 | 31 | /** 32 | * If true (and if 'override' is false), update the component .ts files to use the .static.html templates 33 | */ 34 | updateTemplateUrls: boolean; 35 | 36 | /** 37 | * If true, attempts to comment out the import of the UI Builder stylesheet in the project's stylesheet 38 | */ 39 | commentScssImport: boolean; 40 | 41 | /** 42 | * Path to a JSON file mapping a module's name to a list of component types (eg. {PokemonModule: ['pokemon-image', 'pokemon-name']}) 43 | */ 44 | appModuleDependencies?: string; 45 | 46 | /** 47 | * Detect when the configuration contains images encoded as base64 strings and transform them as files in the assets folder 48 | */ 49 | createBase64Images: boolean; 50 | 51 | /** 52 | * The name of the project. 53 | */ 54 | project?: string; 55 | } 56 | -------------------------------------------------------------------------------- /projects/lib/schematics/ng-add/index.ts: -------------------------------------------------------------------------------- 1 | import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; 2 | import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; 3 | import { addPackageJsonDependency, NodeDependencyType } from '@schematics/angular/utility/dependencies'; 4 | 5 | const packageJson = { 6 | peerDependencies: { 7 | "@ngrx/store": "^14.3.2", 8 | "@popperjs/core": "^2.11.5", 9 | "bootstrap": "^5.2.0", 10 | "immer": "^9.0.16", 11 | "ngrx-wieder": "^9.0.0", 12 | "ngx-drag-drop": "^2.0.0" 13 | }, 14 | devDependencies: { 15 | "@angular/localize": "^14.1.0", 16 | "@types/bootstrap": "^5.2.0", 17 | "htmlparser": "1.7.7", 18 | "html-prettify": "1.0.7", 19 | "sanitize-html": "2.7.1", 20 | } 21 | } 22 | 23 | // Just return the tree 24 | export function ngAdd(): Rule { 25 | return (tree: Tree, context: SchematicContext) => { 26 | 27 | // Add peerDependencies to the dependencies of the app 28 | Object.keys(packageJson.peerDependencies) 29 | .map(lib => ({ 30 | type: NodeDependencyType.Default, 31 | name: lib, 32 | version: packageJson.peerDependencies[lib], 33 | overwrite: true, 34 | })) 35 | .forEach(dep => addPackageJsonDependency(tree, dep)); 36 | 37 | // Add devDependency (specifically, bootstrap types) 38 | Object.keys(packageJson.devDependencies) 39 | .map(lib => ({ 40 | type: NodeDependencyType.Dev, 41 | name: lib, 42 | version: packageJson.devDependencies[lib], 43 | overwrite: true, 44 | })) 45 | .forEach(dep => addPackageJsonDependency(tree, dep)); 46 | 47 | // Run npm install 48 | context.addTask(new NodePackageInstallTask()); 49 | 50 | return tree; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /projects/lib/src/conditions/condition.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from "@angular/core"; 2 | import { Condition, ConditionsService } from "./conditions.service"; 3 | 4 | @Pipe({ 5 | name: 'uibCondition', 6 | standalone: true 7 | }) 8 | export class ConditionPipe implements PipeTransform { 9 | 10 | constructor( 11 | public conditionService: ConditionsService 12 | ){} 13 | 14 | transform(value: any, params?: Condition): boolean { 15 | if(!params) return true; 16 | return this.conditionService.checkData(params, value); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /projects/lib/src/conditions/conditions.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | 3 | 4 | export interface Condition { 5 | data?: string; // conditionsData selector (or undefined for the main data object) 6 | field: string; // The field to select from the data object 7 | type: 'equals' | 'regexp'; 8 | values: ConditionValue[]; 9 | or?: boolean; 10 | } 11 | 12 | export interface ConditionValue { 13 | value: string; 14 | not?: boolean; 15 | $regexp?: RegExp; 16 | } 17 | 18 | 19 | @Injectable({providedIn: 'root'}) 20 | export class ConditionsService { 21 | 22 | public selectData(condition: Condition, conditionsData?: Record, data?: any): any { 23 | return condition.data? conditionsData?.[condition.data] : data; 24 | } 25 | 26 | public check(condition: Condition | undefined, conditionsData?: Record, data?: any): boolean { 27 | if(!condition) return true; 28 | const selectData = this.selectData(condition, conditionsData, data); 29 | return this.checkData(condition, selectData); 30 | } 31 | 32 | public writeCondition(condition: Condition) { 33 | const operator = condition.or? ') OR (' : ') AND ('; 34 | return '('+condition.values.map(v => this.writeValue(condition, v)).join(operator)+')'; 35 | } 36 | 37 | public writeValue(condition: Condition, value: ConditionValue) { 38 | if(condition.type === 'regexp') { 39 | const operator = value.not? '!' : ''; 40 | return `${operator}${condition.data || 'data'}['${condition.field}'].match(/${value.value}/)'`; 41 | } 42 | else if (condition.type === 'equals') { 43 | const operator = value.not? '!==' : '===' 44 | return `${condition.data || 'data'}['${condition.field}'] ${operator} '${value.value}'`; 45 | } 46 | return ''; 47 | } 48 | 49 | public checkData(condition: Condition, data?: any) { 50 | const value = data?.[condition.field]?.toString() || ''; 51 | return this.checkCondition(condition, value); 52 | } 53 | 54 | private checkCondition(condition: Condition, data: string): boolean { 55 | if(condition.or) { 56 | return !!condition.values.find(v => this.checkSingleValue(condition, v, data)); 57 | } 58 | else { 59 | return condition.values.every(v => this.checkSingleValue(condition, v, data)); 60 | } 61 | } 62 | 63 | private checkSingleValue(condition: Condition, value: ConditionValue, data: string) { 64 | let test; 65 | if(condition.type === 'regexp') { 66 | test = this.checkRegexp(value, data); 67 | } 68 | else if(condition.type === 'equals') { 69 | test = this.checkEquals(value, data); 70 | } 71 | return test? !value.not : !!value.not; 72 | } 73 | 74 | 75 | private checkEquals(condition: ConditionValue, value: string): boolean { 76 | return condition.value.toLowerCase() === value.toLowerCase(); 77 | } 78 | 79 | 80 | private checkRegexp(condition: ConditionValue, value: string) { 81 | if(!condition.$regexp) { 82 | try { 83 | condition.$regexp = new RegExp(condition.value, 'i'); 84 | } 85 | catch(e) { 86 | console.warn("Incorrect regular expression ", condition.value); 87 | return true; 88 | } 89 | } 90 | return value.match(condition.$regexp); 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /projects/lib/src/conditions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; 2 | -------------------------------------------------------------------------------- /projects/lib/src/conditions/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './condition.pipe'; 2 | export * from './conditions.service'; 3 | -------------------------------------------------------------------------------- /projects/lib/src/configurable/configurable.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectorRef, 3 | Directive, 4 | ElementRef, 5 | HostBinding, 6 | HostListener, 7 | Input, 8 | OnDestroy, 9 | OnInit, 10 | Renderer2, 11 | } from '@angular/core'; 12 | import { filter, fromEvent, map, merge, Subscription } from 'rxjs'; 13 | import { ZoneContextService } from '../dynamic-views/zone/zone-context.service'; 14 | 15 | import { Configurable, ConfigurableService } from './configurable.service'; 16 | 17 | @Directive({ 18 | selector: '[uib-configurable]', 19 | standalone: true 20 | }) 21 | export class ConfigurableDirective implements OnInit, OnDestroy { 22 | /** 23 | * Element's id (not zone's id) 24 | */ 25 | @Input() id: string; 26 | /** 27 | * Parent's element id 28 | */ 29 | @Input() parentId: string; 30 | /** 31 | * When zone's data exists, which index is used to display the relative data's informations 32 | */ 33 | @Input() dataIndex?: number; 34 | @Input("uib-disable-if") disableIf?: any; 35 | 36 | nativeElement: HTMLElement; 37 | 38 | private subscription: Subscription; 39 | 40 | constructor( 41 | public zoneRef: ZoneContextService, 42 | private configurableService: ConfigurableService, 43 | private renderer: Renderer2, 44 | private cdr: ChangeDetectorRef, 45 | { nativeElement }: ElementRef 46 | ) { 47 | this.nativeElement = nativeElement; 48 | } 49 | 50 | ngOnInit(): void { 51 | this.configurableService.configurableDirectiveMap.set(this.id, this); 52 | 53 | this.subscription = merge( 54 | fromEvent(this.nativeElement, "mouseover"), 55 | fromEvent(this.nativeElement, "mouseenter"), 56 | fromEvent(this.nativeElement, "mouseleave") 57 | ).pipe( 58 | filter(() => !this.disableIf), 59 | map(x => x?.type) 60 | ).subscribe(type => { 61 | switch (type) { 62 | case "mouseover": 63 | this.configurableService.mouseoverConfigurable(this.id); 64 | break; 65 | case "mouseenter": 66 | this.configurableService.mouseenterConfigurable(this.id); 67 | break; 68 | case "mouseleave": 69 | this.configurableService.mouseleaveConfigurable(); 70 | break; 71 | } 72 | this.cdr.markForCheck(); 73 | }) 74 | } 75 | 76 | ngOnDestroy(): void { 77 | this.subscription.unsubscribe(); 78 | } 79 | 80 | @HostBinding('class') 81 | get _class() { 82 | if(this.disableIf) return ''; 83 | return `uib-configurable ${this.highlight() ? 'highlight' : ''}`; 84 | } 85 | 86 | @HostListener('click', ['$event']) 87 | click(event: MouseEvent) { 88 | if(this.disableIf) return; 89 | event.stopPropagation(); 90 | 91 | // before to set 'edited' class to current element 92 | // send it to configurableService to update the previous element and call it's removeEdited() method 93 | const conf: Configurable = { 94 | id: this.id, 95 | parentId: this.parentId, 96 | dataIndex: this.dataIndex, 97 | zone: this.zoneRef.id, 98 | templates: this.zoneRef.templates, 99 | data: this.zoneRef.data, 100 | conditionsData: this.zoneRef.conditionsData, 101 | removeEdited: this.removeEdited.bind(this), 102 | removeSelected: this.removeSelected.bind(this) 103 | }; 104 | this.configurableService.clickConfigurable(conf); 105 | 106 | // now, previous 'edited' class should be correct, 107 | // we can safely set the 'edited' class to the current element 108 | if (this.nativeElement.classList.contains('edited')) { 109 | this.removeEdited(); 110 | this.removeSelected(); 111 | } else { 112 | if (this.zoneRef.id) { 113 | const el = this.configurableService.configurableDirectiveMap.get(this.zoneRef.id); 114 | el?.nativeElement.setAttribute("selected","") 115 | } 116 | 117 | this.renderer.addClass(this.nativeElement, 'edited'); 118 | } 119 | } 120 | 121 | /** 122 | * removes the `edited` class from the `nativeElement` 123 | */ 124 | removeEdited() { 125 | this.renderer.removeClass(this.nativeElement, 'edited'); 126 | } 127 | 128 | /** 129 | * removes the `selected` class from the `nativeElement` 130 | */ 131 | removeSelected() { 132 | if (this.zoneRef.id) { 133 | const el = this.configurableService.configurableDirectiveMap.get(this.zoneRef.id); 134 | el?.nativeElement.removeAttribute("selected"); 135 | } 136 | } 137 | 138 | /** 139 | * removes the `highlight` class from the `nativeElement` 140 | */ 141 | removeHighlight() { 142 | this.renderer.removeClass(this.nativeElement, 'highlight'); 143 | } 144 | 145 | /** 146 | * adds the `highlight` class to the `nativeElement` 147 | */ 148 | addHighlight() { 149 | this.renderer.addClass(this.nativeElement, 'highlight'); 150 | } 151 | 152 | /** 153 | * If the id of the current instance of the component is the same as the id of 154 | * the hovered component, then return true 155 | * @returns The id of the current item is being compared to the hoveredId of the 156 | * configurableService. 157 | */ 158 | highlight() { 159 | return this.id === this.configurableService.hoveredId; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /projects/lib/src/configurable/configurable.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | import { filter } from 'rxjs/operators'; 4 | import { TemplateNameDirective } from '../utils/directive/template-name.directive'; 5 | import {ConfigurableDirective} from './configurable.directive'; 6 | 7 | export interface Configurable { 8 | id: string; 9 | parentId: string; 10 | zone: string; 11 | templates?: Record; 12 | data?: any; 13 | dataIndex?: number; 14 | conditionsData?: Record; 15 | removeEdited: () => void; 16 | removeSelected: () => void; 17 | removeHighlight?: () => void; 18 | addHighlight?: () => void; 19 | highlight?: () => void; 20 | } 21 | 22 | @Injectable({ providedIn: 'root' }) 23 | export class ConfigurableService { 24 | private _hoveredId?: string; 25 | 26 | /** 27 | * currently edited element'id. 28 | * Can be undefined when element is unselected 29 | */ 30 | edited$ = new BehaviorSubject(undefined); 31 | 32 | /** 33 | * behavior subject as we need to retrieve the previous value to toggle it 34 | */ 35 | editorEnabled$ = new BehaviorSubject(false); 36 | 37 | /** 38 | * previous edited element 39 | */ 40 | previousConfigurableElement?: Configurable; 41 | 42 | /** 43 | * configurable service must subscribe to store'changes events (config service) 44 | */ 45 | configurableDirectiveMap: Map = new Map(); 46 | 47 | set hoveredId(id: string | undefined) { 48 | this._hoveredId = id; 49 | } 50 | get hoveredId(): string | undefined { 51 | return this._hoveredId; 52 | } 53 | 54 | /** 55 | * Set the hover's id element when not undefined 56 | * @param {string | undefined} id - The id of the configurable element 57 | */ 58 | mouseoverConfigurable(id: string | undefined) { 59 | if (this._hoveredId === undefined) { 60 | this._hoveredId = id; 61 | } 62 | } 63 | 64 | /** 65 | * Set the hover's id element when different from the current hovered element 66 | * @param id {string | undefined} id - The id of the configurable element 67 | */ 68 | mouseenterConfigurable(id: string | undefined) { 69 | if (id !== this.hoveredId) { 70 | this._hoveredId = id; 71 | } 72 | } 73 | 74 | /** 75 | * When mouse leave configurable element, set hover's id to undefined 76 | */ 77 | mouseleaveConfigurable() { 78 | this._hoveredId = undefined; 79 | } 80 | 81 | clickConfigurable(configurable: Configurable) { 82 | if (!this.previousConfigurableElement) { 83 | // previous is undefined 84 | this.previousConfigurableElement = configurable; 85 | } 86 | else if (this.previousConfigurableElement.id !== configurable.id 87 | || (this.previousConfigurableElement.id === configurable.id && this.previousConfigurableElement.zone !== configurable.zone)) { 88 | // previous element exist and his id don't match with the new configurable element 89 | 90 | this.previousConfigurableElement.removeEdited(); 91 | this.previousConfigurableElement.removeSelected(); 92 | this.previousConfigurableElement = configurable; 93 | } 94 | else if (this.previousConfigurableElement.id === configurable.id && this.previousConfigurableElement.zone === configurable.zone) { 95 | // same id and same zone 96 | this.previousConfigurableElement = undefined; 97 | this.edited$.next(undefined); 98 | return; 99 | } 100 | 101 | this.edited$.next(configurable); 102 | } 103 | 104 | stopEditing() { 105 | this.previousConfigurableElement?.removeEdited(); 106 | this.previousConfigurableElement?.removeSelected(); 107 | this.previousConfigurableElement = undefined; 108 | this.edited$.next(undefined); 109 | } 110 | 111 | watchEdited(): Observable { 112 | return this.edited$.pipe(filter(this.isConfigurable)); 113 | } 114 | 115 | isConfigurable = (configurable: Configurable | undefined): configurable is Configurable => { 116 | return !!configurable; 117 | } 118 | 119 | toggleEditor() { 120 | const enabled = !this.editorEnabled$.value; 121 | this.editorEnabled$.next(enabled); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /projects/lib/src/configurable/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; -------------------------------------------------------------------------------- /projects/lib/src/configurable/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './configurable.directive'; 2 | export * from './configurable.service'; 3 | -------------------------------------------------------------------------------- /projects/lib/src/configuration/config.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from "@ngrx/store"; 2 | import { ComponentConfig } from "./config.model"; 3 | 4 | /** 5 | * Initialize the configuration 6 | */ 7 | export const initConfig = createAction('INIT', props<{config: ComponentConfig[]}>()); 8 | 9 | /** 10 | * Remove the current configuration and set a new one 11 | */ 12 | export const setConfig = createAction('SET', props<{config: ComponentConfig[]}>()); 13 | 14 | /** 15 | * Add a new configuration item 16 | */ 17 | export const addConfig = createAction('ADD', props<{config: ComponentConfig}>()); 18 | 19 | /** 20 | * Remove an existing configuration item 21 | */ 22 | export const removeConfig = createAction('REMOVE', props<{id: string}>()); 23 | 24 | /** 25 | * Update an existing configuration item (or list of items) 26 | */ 27 | export const updateConfig = createAction('UPDATE', props<{config: ComponentConfig | ComponentConfig[]}>()); 28 | -------------------------------------------------------------------------------- /projects/lib/src/configuration/config.model.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from "../conditions"; 2 | 3 | type StringWithAutocomplete = T | (string & Record); 4 | 5 | /** 6 | * An object containing the configuration of a component. 7 | */ 8 | export type ComponentConfig = { 9 | /** Unique string identifying the component */ 10 | readonly id: string, 11 | /** The type is should correspond to a template injected in a 12 | uib-zone component, so that this component can be displayed */ 13 | type: StringWithAutocomplete<'_container' | '_raw'>, 14 | /** Optional list of CSS class injected in the div 15 | wrapping the template of this component */ 16 | classes?: string, 17 | /** Any parameter needed to display the component */ 18 | [key: string]: any; 19 | /** An optional condition to display the component only when 20 | some conditions are met */ 21 | condition?: Condition, 22 | /** Any custom display name */ 23 | display?: string, 24 | } 25 | 26 | /** 27 | * A specific type of object configuration for "containers", including 28 | * the list of items that this container contains. 29 | */ 30 | export interface ContainerConfig extends ComponentConfig { 31 | type: '_container'; 32 | /** List of component ids displayed in this container */ 33 | items: string[]; 34 | } 35 | -------------------------------------------------------------------------------- /projects/lib/src/configuration/config.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { StoreModule } from "@ngrx/store"; 3 | import { uibConfig } from "./config.reducer"; 4 | 5 | @NgModule({imports: [StoreModule.forFeature(uibConfig)]}) 6 | export class ConfigModule {} 7 | -------------------------------------------------------------------------------- /projects/lib/src/configuration/config.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createFeature, on } from "@ngrx/store"; 2 | import { initialUndoRedoState, undoRedo, UndoRedoState } from "ngrx-wieder"; 3 | import { addConfig, initConfig, removeConfig, setConfig, updateConfig } from "./config.actions"; 4 | import { ComponentConfig } from "./config.model"; 5 | 6 | export interface ConfigState extends UndoRedoState { 7 | config: {[id: string]: ComponentConfig}; 8 | } 9 | 10 | const { createUndoRedoReducer } = undoRedo({ 11 | allowedActionTypes: [addConfig.type, removeConfig.type, updateConfig.type], 12 | maxBufferSize: Number.MAX_SAFE_INTEGER 13 | }); 14 | 15 | function storeConfig(state: ConfigState, config: ComponentConfig[]) { 16 | for(let c of config) { 17 | state.config[c.id] = c; 18 | } 19 | return state; 20 | } 21 | 22 | const reducer = createUndoRedoReducer( 23 | {config: {}, ...initialUndoRedoState}, 24 | 25 | on(initConfig, (state, {config}) => storeConfig(state, config)), 26 | 27 | on(setConfig, (state, {config}) => { 28 | // Clear current state 29 | for(let key of Object.keys(state)) { 30 | delete state[key]; 31 | } 32 | // Add new state 33 | return storeConfig(state, config); 34 | }), 35 | 36 | on(addConfig, (state, {config}) => { 37 | if(state.config[config.id]) { 38 | throw new Error(`Config ${config.id} already exists.`); 39 | } 40 | return storeConfig(state, [config]); 41 | }), 42 | 43 | on(removeConfig, (state, {id}) => { 44 | delete state.config[id]; 45 | return state; 46 | }), 47 | 48 | on(updateConfig, (state, {config}) => { 49 | if(!Array.isArray(config)) config = [config]; 50 | return storeConfig(state, config); 51 | }) 52 | ); 53 | 54 | 55 | export const uibConfig = createFeature({ name: 'uibConfig', reducer }); 56 | -------------------------------------------------------------------------------- /projects/lib/src/configuration/config.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from "@ngrx/store"; 2 | import { createHistorySelectors } from "ngrx-wieder"; 3 | import { uibConfig, ConfigState } from "./config.reducer"; 4 | 5 | const selectConfig = uibConfig.selectUibConfigState; 6 | 7 | export const selectAll = createSelector(selectConfig, (s: ConfigState) => Object.values(s.config)); 8 | export const selectItem = (id: string) => createSelector(selectConfig, (s: ConfigState) => s.config[id]); 9 | export const { 10 | selectCanUndo, 11 | selectCanRedo, 12 | } = createHistorySelectors(selectConfig); 13 | 14 | -------------------------------------------------------------------------------- /projects/lib/src/configuration/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | import { filter, map, take } from 'rxjs/operators'; 5 | import { initConfig, removeConfig, setConfig, updateConfig } from './config.actions'; 6 | import { ComponentConfig, ContainerConfig } from './config.model'; 7 | import { selectAll, selectCanRedo, selectCanUndo, selectItem } from './config.selectors'; 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class ConfigService { 11 | 12 | constructor( 13 | public store: Store 14 | ){} 15 | 16 | /** 17 | * Initialize configuration assuming the store is empty. 18 | * No object is removed and the state history is not modified. 19 | * @param config list of configuration items to manage 20 | */ 21 | public init(config: ComponentConfig[]) { 22 | this.store.dispatch(initConfig({config})); 23 | } 24 | 25 | /** 26 | * Set the configuration assuming the store is not empty. 27 | * All previously existing objects are removed and the state 28 | * history is reset. 29 | * @param config 30 | */ 31 | public set(config: ComponentConfig[]) { 32 | this.store.dispatch({type: 'CLEAR'}); // Clear the previous history, as the previous actions won't work on new objects 33 | this.store.dispatch(setConfig({config})); 34 | } 35 | 36 | /** 37 | * Watch any change in the configuration objects. 38 | */ 39 | public watchAllConfig(): Observable { 40 | return this.store.select(selectAll); 41 | } 42 | 43 | /** 44 | * @returns all current configuration 45 | */ 46 | public getAllConfig(): ComponentConfig[] { 47 | let config: ComponentConfig[] = []; 48 | this.watchAllConfig().pipe(take(1)).subscribe(c => config = c); 49 | return config; 50 | } 51 | 52 | /** 53 | * Watch changes of one specific configuration object 54 | */ 55 | public watchConfig(id: string): Observable { 56 | this.getConfig(id); // Ensure a value exists (if 'id' has no config) 57 | return this.store.select(selectItem(id)).pipe( 58 | filter(config => !!config), 59 | map((config: ComponentConfig) => JSON.parse(JSON.stringify(config))) 60 | ); 61 | } 62 | 63 | private _getConfig(id: string): ComponentConfig | undefined { 64 | let config: ComponentConfig | undefined; 65 | this.store.select(selectItem(id)) 66 | .pipe(take(1)) 67 | .subscribe(c => config = c); 68 | return config; 69 | } 70 | 71 | /** 72 | * @returns the current configuration of a specific item with the given id 73 | */ 74 | public getConfig(id: string): ComponentConfig { 75 | let config = this._getConfig(id); 76 | if (!config) { 77 | config = { id, type: id }; 78 | this.store.dispatch(initConfig({config: [config]})); // Use init instead of add, because add is undoable 79 | } 80 | return JSON.parse(JSON.stringify(config)); // Deep copy 81 | } 82 | 83 | /** 84 | * @returns the current configuration of a specific container item with the given id 85 | */ 86 | public getContainer(id: string): ContainerConfig { 87 | const config = this.getConfig(id); 88 | if (!this.isContainerConfig(config)) { 89 | throw `${id} is not a container`; 90 | } 91 | return config; 92 | } 93 | 94 | /** 95 | * @returns true if the configuration with the given id is a container 96 | */ 97 | public isContainer(id: string): boolean { 98 | return this.isContainerConfig(this._getConfig(id)); 99 | } 100 | 101 | /** 102 | * @returns true if the given configuration is a container 103 | */ 104 | public isContainerConfig(conf: ComponentConfig|undefined): conf is ContainerConfig { 105 | return conf?.type === '_container'; 106 | } 107 | 108 | /** 109 | * Test whether a given component id is used within the hierarchy of a container 110 | */ 111 | public isUsed(id: string) { 112 | return !!this.findParent(id); 113 | } 114 | 115 | /** 116 | * @returns the configuration of a container that includes the given id as a child item 117 | */ 118 | public findParent(id: string): ContainerConfig | undefined { 119 | return this.getAllConfig() 120 | .find(item => this.isContainerConfig(item) && item.items.includes(id)) as ContainerConfig | undefined; 121 | } 122 | 123 | /** 124 | * Update the configuration of a given component or list of components 125 | */ 126 | public updateConfig(config: ComponentConfig | ComponentConfig[]) { 127 | this.store.dispatch(updateConfig({config})); 128 | } 129 | 130 | /** 131 | * Remove the configuration of a component with the given id 132 | */ 133 | public removeConfig(id: string) { 134 | this.store.dispatch(removeConfig({id})); 135 | } 136 | 137 | /** 138 | * @returns a new unique component id for the given component type 139 | */ 140 | public generateId(type: string) { 141 | let idx = 1; 142 | let root = type.startsWith("_")? type.slice(1) : type; 143 | const tokens = type.split("-"); 144 | if(tokens[tokens.length-1].match(/\d+/)) { 145 | idx = +tokens[tokens.length-1]; 146 | root = tokens.slice(0, tokens.length-1).join('-'); 147 | } 148 | let id = root; 149 | do { 150 | id = `${root}-${idx++}`; 151 | } while (this._getConfig(id) || id === type); 152 | return id; 153 | } 154 | 155 | /** 156 | * @returns an observable state for the possibility of undoing the last action 157 | */ 158 | public canUndo$(): Observable { 159 | return this.store.select(selectCanUndo()); 160 | } 161 | 162 | /** 163 | * @returns an observable state for the possibility of redoing the next action 164 | */ 165 | public canRedo$(): Observable { 166 | return this.store.select(selectCanRedo()); 167 | } 168 | 169 | /** 170 | * Undo the last action if possible 171 | */ 172 | public undo() { 173 | this.canUndo$().pipe(take(1)).subscribe( 174 | can => can && this.store.dispatch({type: "UNDO"}) 175 | ); 176 | } 177 | 178 | /** 179 | * Redo the next action if possible 180 | */ 181 | public redo() { 182 | this.canRedo$().pipe(take(1)).subscribe( 183 | can => can && this.store.dispatch({type: "REDO"}) 184 | ); 185 | } 186 | 187 | public exportConfiguration() { 188 | const config = JSON.stringify(this.getAllConfig(), null, 2); 189 | var element = document.createElement('a'); 190 | element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(config)); 191 | element.setAttribute('download', "config.json"); 192 | element.style.display = 'none'; 193 | document.body.appendChild(element); 194 | element.click(); 195 | document.body.removeChild(element); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /projects/lib/src/configuration/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; -------------------------------------------------------------------------------- /projects/lib/src/configuration/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './config.service'; 2 | export * from './config.model'; 3 | export * from './config.module'; 4 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/checkbox/checkbox.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from "@angular/core"; 2 | import { FormsModule } from "@angular/forms"; 3 | 4 | import { ConfiguratorContext } from "../configurator.models"; 5 | 6 | @Component({ 7 | selector: 'uib-checkbox', 8 | imports: [FormsModule], 9 | standalone: true, 10 | template: ` 11 |
12 | 13 | 14 |
15 | ` 16 | }) 17 | export class CheckboxComponent implements OnChanges { 18 | @Input() context: ConfiguratorContext; 19 | @Input() property: string; 20 | @Input() label?: string; 21 | 22 | @Output() modelChanged = new EventEmitter(); 23 | 24 | _path: string[]; 25 | 26 | get model(): boolean { 27 | let val = this.context.config; 28 | for (let p of this._path) { 29 | val = val[p]; 30 | } 31 | return !!val; 32 | } 33 | 34 | set model(value: boolean) { 35 | let val = this.context.config; 36 | let i = 0; 37 | for (; i < this._path.length - 1; i++) { 38 | val = val[this._path[i]]; 39 | } 40 | if (value !== val[this._path[i]]) { 41 | val[this._path[i]] = value; 42 | this.context.configChanged(); 43 | this.modelChanged.emit(value); 44 | } 45 | } 46 | 47 | ngOnChanges(changes: SimpleChanges): void { 48 | this._path = this.property.split('.'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/color-picker/color-picker.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; 2 | import { FormsModule } from "@angular/forms"; 3 | 4 | import { ConfiguratorContext } from "../configurator.models"; 5 | import { NgModelChangeDebouncedDirective, TooltipDirective } from "../../utils"; 6 | import { CommonModule } from "@angular/common"; 7 | 8 | @Component({ 9 | selector: 'uib-color-picker', 10 | imports: [CommonModule, FormsModule, TooltipDirective, NgModelChangeDebouncedDirective], 11 | standalone: true, 12 | template: ` 13 | 14 |
15 | 21 | 22 |
23 | `, 24 | styles: [` 25 | :host { 26 | width: 100px; 27 | } 28 | 29 | .color-picker-wrapper { 30 | overflow: hidden; 31 | width: 48px; 32 | height: 48px; 33 | border-radius: 50%; 34 | box-shadow: 1px 1px 3px 0px grey; 35 | margin: auto; 36 | } 37 | 38 | .color-picker-wrapper input[type=color] { 39 | width: 150%; 40 | /* height: 150%; */ 41 | padding: 0; 42 | margin: -25%; 43 | } 44 | 45 | .color-picker-reset { 46 | position: relative; 47 | margin: 0; 48 | padding: 0; 49 | width: 125%; 50 | height: 60%; 51 | top: -10px; 52 | left: -5px; 53 | } 54 | `] 55 | }) 56 | export class ColorPickerComponent implements OnChanges { 57 | @Input() context: ConfiguratorContext; 58 | @Input() property: string; 59 | @Input() label?: string; 60 | @Input() defaultColor = '#ffffff'; 61 | @Input() tooltip: string = ''; 62 | 63 | _path: string[]; 64 | 65 | get color() { 66 | let val = this.context.config; 67 | for(let p of this._path) { 68 | val = val[p]; 69 | } 70 | return val || this.defaultColor; 71 | } 72 | 73 | set color(value: string) { 74 | if(value.toLowerCase() === this.defaultColor.toLowerCase()) { 75 | value = ''; 76 | } 77 | let val = this.context.config; 78 | let i = 0; 79 | // walk through nested object's properties 80 | // at the for loop end, val will contains the seeked ref property 81 | for(; i 2 | 3 |
4 |
5 | {{edited.config.id}} 6 |
(Type: {{edited.context.templates?.[edited.config.type]?.display || edited.config.type}})
7 | 8 |
Configuration Tree
9 | 10 | 13 | 16 | 19 | 20 |
21 | 22 | 23 |
24 | 25 | 26 | 27 |
28 | {{description}} 29 |
30 |
31 | 32 | 34 | 35 | 36 | 37 |
38 | Component palette 39 | 44 | 45 |
46 | 47 |
48 | Layout 49 | 50 |
51 |
52 | 53 | 56 | 57 | 58 | 59 | 60 |
61 | 62 | 63 |
64 | 65 |
66 | 67 |
68 | 69 |
70 | 71 |
74 | Conditional display 75 | 76 |
77 | 78 |
79 | 87 | 95 |
96 |
97 | 98 | 99 | 100 |
101 | 102 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/configurator.component.scss: -------------------------------------------------------------------------------- 1 | .offcanvas-body { 2 | padding-bottom: 90px; /* avoid toolbar hiding bottom of configurator */ 3 | } 4 | 5 | .ltr > svg-icon { 6 | transform: rotate(180deg) translateY(-4px); 7 | } -------------------------------------------------------------------------------- /projects/lib/src/configurator/configurator.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | ChangeDetectorRef, 4 | Component, 5 | ContentChildren, 6 | ElementRef, 7 | Input, 8 | OnInit, 9 | QueryList, 10 | ViewChild, 11 | } from '@angular/core'; 12 | import { Offcanvas } from 'bootstrap'; 13 | import { switchMap, map, tap } from 'rxjs/operators'; 14 | import { Observable } from 'rxjs'; 15 | import { Configurable, ConfigurableService } from '../configurable/configurable.service'; 16 | import { ComponentConfig, ConfigService, ContainerConfig } from '../configuration'; 17 | import { Mutable } from '../utils/typings'; 18 | import { PaletteComponent, defaultPaletteOptions } from './palette/palette.component'; 19 | import { ConfiguratorContext, ConfiguratorOptions } from './configurator.models'; 20 | import { TemplateNameDirective } from '../utils/directive/template-name.directive'; 21 | import { CommonModule } from '@angular/common'; 22 | import { SvgIconComponent, TooltipDirective } from '../utils'; 23 | import { ClassEditorComponent, ConditionEditorComponent, FlexEditorComponent, HtmlEditorComponent, SpacingEditorComponent } from './editors'; 24 | import { TreeComponent } from './public-api'; 25 | 26 | export const defaultConfiguratorOptions: ConfiguratorOptions = { 27 | paletteOptions: defaultPaletteOptions, 28 | showFlexEditor: true, 29 | showHtmlEditor: true, 30 | showCssClasses: true, 31 | showSpacingEditor: true, 32 | showConditionalDisplay: true, 33 | showRemove: true, 34 | showDuplicate: true 35 | } 36 | 37 | @Component({ 38 | selector: 'uib-configurator', 39 | standalone: true, 40 | imports: [ 41 | CommonModule, 42 | TooltipDirective, 43 | SvgIconComponent, 44 | TreeComponent, 45 | HtmlEditorComponent, 46 | FlexEditorComponent, 47 | PaletteComponent, 48 | ClassEditorComponent, 49 | SpacingEditorComponent, 50 | ConditionEditorComponent, 51 | ], 52 | templateUrl: './configurator.component.html', 53 | styleUrls: ['./configurator.component.scss'], 54 | changeDetection: ChangeDetectionStrategy.OnPush, 55 | }) 56 | export class ConfiguratorComponent implements OnInit { 57 | // Capture configurator templates 58 | @ContentChildren(TemplateNameDirective) 59 | children: QueryList; 60 | configurators: Record = {}; 61 | 62 | @ViewChild('offcanvas') offcanvasEl: ElementRef; 63 | offcanvas: Offcanvas; 64 | 65 | @ViewChild('offcanvasBody') offcanvasBodyEl:ElementRef; 66 | 67 | @Input() options = defaultConfiguratorOptions; 68 | @Input() zoneOptions: Record = {}; 69 | 70 | edited$: Observable; 71 | 72 | configuration: ComponentConfig[] = []; 73 | 74 | isTree: boolean; 75 | ltr = false; 76 | parentId: string; 77 | 78 | constructor( 79 | private cdr: ChangeDetectorRef, 80 | public configurableService: ConfigurableService, 81 | public configService: ConfigService 82 | ) {} 83 | 84 | ngOnInit(): void { 85 | this.edited$ = this.configurableService.watchEdited().pipe( 86 | tap(() => this.offcanvas.show()), 87 | tap(() => this.showTree(false)), 88 | tap((edited) => this.parentId = edited.parentId), 89 | switchMap((context) => 90 | this.configService.watchConfig(context!.id).pipe( 91 | map(config => ({ 92 | context, 93 | config, 94 | options: this.resolveOptions(context.zone), 95 | configurators: this.configurators, 96 | configChanged: () => this.configService.updateConfig(config) 97 | })) 98 | ) 99 | ) 100 | ); 101 | 102 | // subscribe to configuration events 103 | this.configService.watchAllConfig().subscribe(config => { 104 | this.configuration = config!; 105 | this.cdr.markForCheck(); 106 | }); 107 | 108 | // when edition is disabled, close side panel 109 | this.configurableService.editorEnabled$.subscribe(value => { 110 | if (value === false && this.offcanvas) { 111 | this.offcanvas.hide(); 112 | } 113 | }); 114 | } 115 | 116 | /** 117 | * Create Bootstrap OffCanvas component 118 | */ 119 | ngAfterViewInit() { 120 | this.offcanvas = Offcanvas.getOrCreateInstance(this.offcanvasEl.nativeElement, { 121 | backdrop: false, 122 | scroll: true 123 | }); 124 | this.offcanvasEl.nativeElement.addEventListener('hide.bs.offcanvas', _ => { 125 | this.configurableService.stopEditing(); 126 | }); 127 | } 128 | 129 | /** 130 | * Extract list of configuration editors 131 | */ 132 | ngAfterContentInit() { 133 | this.children.forEach( 134 | template => (this.configurators[template.name] = template) 135 | ); 136 | } 137 | 138 | showTree(showTree = true) { 139 | this.isTree = showTree; 140 | this.offcanvasBodyEl.nativeElement.scroll(0, 0); 141 | } 142 | 143 | resolveOptions(zone: string) { 144 | // First set defaults, then the configurator options, then zone-specific options 145 | const options = Object.assign({}, defaultConfiguratorOptions, this.options, this.zoneOptions[zone] || {}); 146 | // Same thing for the nested palette options 147 | options.paletteOptions = Object.assign({}, defaultPaletteOptions, this.options.paletteOptions, this.zoneOptions[zone]?.paletteOptions || {}); 148 | return options; 149 | } 150 | 151 | /** 152 | * It removes the item from the parent container. 153 | * @param {Event} event - Event 154 | */ 155 | remove(context: Configurable) { 156 | // only uib-zone cannot self remove 157 | if (context.parentId) { 158 | const container = this.configService.getContainer(context.parentId); 159 | const index = container.items.findIndex(item => item === context.id); 160 | if (index !== -1) { 161 | container.items.splice(index, 1); 162 | this.configService.updateConfig([container]); 163 | this.offcanvas.toggle(); 164 | } 165 | } 166 | } 167 | 168 | duplicate(context: Configurable) { 169 | const config: Mutable = this.configService.getConfig(context.id); 170 | config.id = this.configService.generateId(config.id); // Generate a new config id 171 | if(context.parentId) { 172 | const container = this.configService.getContainer(context.parentId); 173 | const index = container.items.findIndex(item => item === context.id); 174 | if (index !== -1) { 175 | container.items.splice(index+1, 0, config.id); 176 | this.configService.updateConfig([config, container]); 177 | } 178 | } 179 | // Special case of a zone 180 | else if(context.zone === context.id) { 181 | // Create another copy 182 | const config2: Mutable = this.configService.getConfig(context.id); 183 | config2.id = this.configService.generateId(config.id); 184 | 185 | const container: ContainerConfig = { 186 | id: context.id, 187 | type: '_container', 188 | items: [config.id, config2.id], 189 | classes: "flex-column", 190 | }; 191 | 192 | this.configService.updateConfig([config, config2, container]); 193 | } 194 | } 195 | 196 | goToParent() { 197 | const el = this.configurableService.configurableDirectiveMap.get(this.parentId); 198 | el?.click(new MouseEvent("click")); 199 | el?.nativeElement.scrollIntoView({behavior: 'smooth', inline: 'nearest', block: 'center'}); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/configurator.models.ts: -------------------------------------------------------------------------------- 1 | import { Configurable } from "../configurable"; 2 | import { ComponentConfig } from "../configuration"; 3 | import { TemplateNameDirective } from "../utils"; 4 | 5 | export interface ConfiguratorOptions { 6 | paletteOptions?: PaletteOptions; 7 | showFlexEditor?: boolean; 8 | showHtmlEditor?: boolean; 9 | showCssClasses?: boolean; 10 | showSpacingEditor?: boolean; 11 | showConditionalDisplay?: boolean; 12 | showRemove?: boolean; 13 | showDuplicate?: boolean; 14 | } 15 | 16 | export interface PaletteOptions { 17 | enableSubcontainers?: boolean; 18 | enableRawHtml?: boolean; 19 | rawHtmlPlaceholder?: string; 20 | showStandardPalette?: boolean; 21 | showExistingPalette?: boolean; 22 | } 23 | 24 | export interface ConfiguratorContext { 25 | /** Object storing the configuration of the component */ 26 | config: ComponentConfig; 27 | /** Options of the configurators (may change depending on zone) */ 28 | options: ConfiguratorOptions; 29 | /** Register of all the components configurators */ 30 | configurators: Record; 31 | /** Context of the zone of the edited component */ 32 | context: Configurable; 33 | /** Callback that the configurator should call to update the configuration */ 34 | configChanged: () => void; 35 | }; 36 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/class-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | import { ConfiguratorContext } from '../configurator.models'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { NgModelChangeDebouncedDirective } from '../../utils'; 5 | 6 | @Component({ 7 | selector: 'uib-class-editor', 8 | standalone: true, 9 | imports: [FormsModule, NgModelChangeDebouncedDirective], 10 | template: ` 11 | 12 | 22 | `, 23 | changeDetection: ChangeDetectionStrategy.OnPush 24 | }) 25 | export class ClassEditorComponent { 26 | @Input() context: ConfiguratorContext; 27 | } 28 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/condition-editor.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 | 7 |
8 | 9 |
10 | 11 | 15 |
16 | 17 |
18 | 19 | 20 | 21 |
22 | 23 |
24 | 25 | 29 |
30 | 31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 | 41 | 42 |
43 | 44 | 45 |
46 |
47 | 48 |
49 | 50 | 51 |
52 | 53 | 54 |
55 |
Debug text
56 |
{{debugText}}
57 |
58 | 59 |
-------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/condition-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input, OnChanges } from "@angular/core"; 2 | import { Condition, ConditionsService, ConditionValue } from "../../conditions/conditions.service"; 3 | import { ConfiguratorContext } from "../configurator.models"; 4 | import { AutocompleteComponent, NgModelChangeDebouncedDirective } from "../../utils"; 5 | import { FormsModule } from "@angular/forms"; 6 | import { CommonModule } from "@angular/common"; 7 | 8 | @Component({ 9 | selector: 'uib-condition-editor', 10 | standalone: true, 11 | imports: [CommonModule, FormsModule, AutocompleteComponent, NgModelChangeDebouncedDirective], 12 | templateUrl: './condition-editor.component.html', 13 | styles: [` 14 | .condition-value .btn { 15 | width: 40px; 16 | } 17 | .autocomplete .list-group { 18 | top: 0; 19 | width: 100%; 20 | max-height: 200px; 21 | overflow: auto; 22 | } 23 | `], 24 | changeDetection: ChangeDetectionStrategy.OnPush 25 | }) 26 | export class ConditionEditorComponent implements OnChanges { 27 | @Input() context: ConfiguratorContext; 28 | 29 | // The data on which the current condition is evaluated 30 | data: any; 31 | // The data field on which the current condition is evaluated 32 | fields: string[]; 33 | // The list of values tested by the current condition 34 | values: string[]; 35 | // A debug text to understand the current condition 36 | debugText: string; 37 | 38 | constructor( 39 | public conditionsService: ConditionsService 40 | ){} 41 | 42 | ngOnChanges() { 43 | this.updateConfig(false); 44 | } 45 | 46 | updateConfig(notify = true) { 47 | if(this.context.config.condition) { 48 | this.updateData(this.context.config.condition); 49 | this.updateFields(); 50 | this.updateValues(this.context.config.condition); 51 | this.updateDebugText(this.context.config.condition); 52 | } 53 | if(notify) { 54 | this.context.configChanged(); 55 | } 56 | } 57 | 58 | updateData(condition: Condition) { 59 | const data = typeof this.context.context.dataIndex === 'undefined'? 60 | this.context.context.data : this.context.context.data[this.context.context.dataIndex]; 61 | this.data = this.conditionsService.selectData(condition, this.context.context.conditionsData, data); 62 | } 63 | 64 | updateFields() { 65 | this.fields = this.data? Object.keys(this.data) : []; 66 | } 67 | 68 | updateValues(condition: Condition) { 69 | this.values = []; 70 | if(condition.field && this.data) { 71 | if(condition.data || typeof this.context.context.dataIndex === 'undefined') { 72 | this.addValueToList(this.values, this.data, condition.field); 73 | } 74 | // Special case of the data list 75 | else { 76 | this.context.context.data.forEach(item => this.addValueToList(this.values, item, condition.field)); 77 | } 78 | } 79 | } 80 | 81 | private addValueToList(values: string[], item: any, field: string) { 82 | const data = item[field]?.toString(); 83 | if(data && !values.includes(data)) { 84 | values.push(data); 85 | } 86 | } 87 | 88 | updateDebugText(condition: Condition) { 89 | this.debugText = this.conditionsService.writeCondition(condition); 90 | } 91 | 92 | get activate(): boolean { 93 | return !!this.context.config.condition; 94 | } 95 | 96 | set activate(value: boolean) { 97 | if(value) { // Create a new condition from scratch 98 | this.context.config.condition = {data: '', type: 'equals', field: '', values: [{value: ''}]}; 99 | } 100 | else { // Erase the current condition 101 | delete this.context.config.condition; 102 | } 103 | this.updateConfig(); 104 | } 105 | 106 | addValue() { 107 | this.context.config.condition?.values.push({value: ''}); 108 | } 109 | 110 | removeValue(i: number) { 111 | this.context.config.condition?.values.splice(i, 1); 112 | this.updateConfig(); 113 | } 114 | 115 | selectField(field: string) { 116 | this.context.config.condition!.field = field; 117 | this.updateConfig(); 118 | } 119 | 120 | selectValue(value: string, condition: ConditionValue) { 121 | condition.value = value; 122 | this.updateConfig(); 123 | } 124 | 125 | // This item instance might change, so we track by index 126 | trackByFn(index, item) { 127 | return index; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/flex-editor.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
Direction
4 |
5 | 6 | 7 | 10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 |
24 | 25 | 26 |
27 |
{{ name | titlecase }}
28 |
29 | 30 | 35 | 38 | 39 |
40 |
41 |
42 | 43 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/flex-editor.component.scss: -------------------------------------------------------------------------------- 1 | .editor-line { 2 | display: grid; 3 | grid-template-columns: 105px 1fr; 4 | align-items: center; 5 | margin-bottom: .5rem; 6 | } 7 | 8 | /* ================ RADIO BUTTONS ================= */ 9 | 10 | .radio-group { 11 | // --rotate: 90deg; 12 | --bg-checked: hsl(80, 73%, 75%); 13 | --bg-hover: hsl(80, 61%, 50%); 14 | 15 | display: flex; 16 | align-items: center; 17 | gap: 0.2rem; 18 | } 19 | 20 | .radio-group label { 21 | position: relative; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | 26 | width: 36px; 27 | height: 36px; 28 | 29 | > svg { 30 | transform: rotate(var(--rotate, 0deg)); 31 | transition: transform 0.2s ease-in-out; 32 | } 33 | } 34 | 35 | .radio-group input[type='radio'] { 36 | display: none; 37 | } 38 | 39 | .radio-group input[type='radio'] + label { 40 | cursor: pointer; 41 | padding: 0.3rem 0.5rem; 42 | border-radius: 4px; 43 | border: 1px dashed #ccc; 44 | transition: background-color 0.2s ease-in-out, fill 0.2s ease-in-out, color 0.2s ease-in-out; 45 | } 46 | 47 | .radio-group input[type='radio']:checked + label { 48 | border: 1px solid #000; 49 | background-color: var(--bg-checked); 50 | } 51 | 52 | .radio-group input[type='radio'] + label:hover { 53 | color: white; 54 | fill: white; 55 | background-color: var(--bg-hover); 56 | } 57 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/flex-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; 2 | import { ComponentConfig, ConfigService } from '../../configuration'; 3 | import { TooltipDirective, TooltipPlacement } from '../../utils'; 4 | import { SvgIconComponent } from '../../utils/svg-icon/svg-icon.component'; 5 | import { CommonModule } from '@angular/common'; 6 | 7 | declare interface FlexOption { 8 | key: string; 9 | text: string; 10 | value: string; 11 | bootstrap: string; 12 | } 13 | 14 | @Component({ 15 | selector: 'uib-flex-editor', 16 | standalone: true, 17 | imports: [CommonModule, TooltipDirective, SvgIconComponent], 18 | templateUrl: 'flex-editor.component.html', 19 | styleUrls: ['./flex-editor.component.scss'], 20 | }) 21 | export class FlexEditorComponent implements OnChanges { 22 | @Input() config: ComponentConfig; 23 | 24 | // tooltip's placement 25 | placement: TooltipPlacement = "bottom"; 26 | 27 | classes: string[]; 28 | 29 | get direction(): 'row' | 'column' { 30 | return this.classes.includes('flex-column') ? 'column' : 'row'; 31 | } 32 | 33 | directions: FlexOption[] = [ 34 | {key: 'direction-right', text: $localize `horizontal`, value: 'row', bootstrap: 'flex-row'}, 35 | {key: 'direction-bottom', text: $localize `vertical`, value: 'column', bootstrap: 'flex-column'} 36 | ] 37 | 38 | justify: FlexOption[] = [ 39 | {key:'align_x_start', text: $localize `start`, value: 'flex-start', bootstrap: 'justify-content-start'}, 40 | {key:'align_x_center', text: $localize `center`, value: 'center', bootstrap: 'justify-content-center'}, 41 | {key:'align_x_end', text: $localize `end`, value: 'flex-end', bootstrap: 'justify-content-end'}, 42 | {key:'align_x_space_around', text: $localize `space around`, value: 'space-around', bootstrap: 'justify-content-around'}, 43 | {key:'align_x_space_between', text: $localize `space between`, value: 'space-between', bootstrap: 'justify-content-between'} 44 | ] 45 | 46 | alignmentHorizontal: FlexOption[] = [ 47 | {key: 'align_y_start', text: $localize `top`, value: 'flex-start', bootstrap: 'align-items-start'}, 48 | {key: 'align_y_center', text: $localize `center`, value: 'center', bootstrap: 'align-items-center'}, 49 | {key: 'align_y_end', text: $localize `bottom`, value: 'flex-end', bootstrap: 'align-items-end'}, 50 | {key: 'align_y_stretch', text: $localize `stretch`, value: 'stretch', bootstrap: 'align-items-stretch'}, 51 | {key: 'align_y_baseline', text: $localize `baseline`, value: 'baseline', bootstrap: 'align-items-baseline'}, 52 | ] 53 | 54 | alignmentVertical: FlexOption[] = [ 55 | {key:'align_x_start', text: $localize `start`, value: 'flex-start', bootstrap: 'align-items-start'}, 56 | {key:'align_x_center', text: $localize `center`, value: 'center', bootstrap: 'align-items-center'}, 57 | {key:'align_x_end', text: $localize `end`, value: 'flex-end', bootstrap: 'align-items-end'}, 58 | {key:'align_x_stretch', text: $localize `stretch`, value: 'stretch', bootstrap: 'align-items-stretch'}, 59 | ] 60 | 61 | get alignment(): FlexOption[] { 62 | return this.direction === 'column'? this.alignmentVertical : this.alignmentHorizontal; 63 | } 64 | 65 | 66 | constructor( 67 | public configService: ConfigService 68 | ) {} 69 | 70 | ngOnChanges(changes: SimpleChanges): void { 71 | if (changes.config) { 72 | // convert "classes" string into array 73 | this.classes = this.config.classes?.split(' ') || []; 74 | } 75 | } 76 | 77 | private updateConfig() { 78 | this.config.classes = this.classes.join(' '); 79 | this.configService.updateConfig(this.config); 80 | } 81 | 82 | toggleClass(style: string, turnOff: FlexOption[] = []) { 83 | const i = this.classes.indexOf(style); 84 | // Remove all classes matching a flex option 85 | this.classes = this.classes.filter(c => c !== style && !turnOff.find(o => o.bootstrap === c)); 86 | // Then add the class, unless it was already there (in which case, the filter turned it off) 87 | if(i === -1) { 88 | this.classes.push(style); 89 | } 90 | this.updateConfig(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/html-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from "@angular/core"; 2 | import { ConfiguratorContext } from "../configurator.models"; 3 | import { FormsModule } from "@angular/forms"; 4 | import { NgModelChangeDebouncedDirective } from "../../utils"; 5 | 6 | @Component({ 7 | selector: 'uib-html-editor', 8 | standalone: true, 9 | imports: [FormsModule, NgModelChangeDebouncedDirective], 10 | template: ` 11 | 12 |

Static HTML content (text, images). Does not support Angular component and syntax.

13 | 20 | `, 21 | styles: [` 22 | textarea { 23 | font-family: monospace; 24 | font-size: 12px !important; 25 | } 26 | `] 27 | }) 28 | export class HtmlEditorComponent { 29 | @Input() context: ConfiguratorContext; 30 | 31 | } -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './class-editor.component'; 2 | export * from './condition-editor.component'; 3 | export * from './flex-editor.component'; 4 | export * from './html-editor.component'; 5 | export * from './spacing-editor.component'; 6 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/editors/spacing-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnChanges, QueryList, Renderer2, SimpleChanges, ViewChildren } from "@angular/core"; 2 | import { ComponentConfig, ConfigService } from "../../configuration"; 3 | import { TooltipDirective } from "../../utils"; 4 | import { SvgIconComponent } from "../../utils/svg-icon/svg-icon.component"; 5 | import { CommonModule } from "@angular/common"; 6 | 7 | @Component({ 8 | selector: 'uib-bs-spacing-editor', 9 | standalone: true, 10 | imports: [CommonModule, TooltipDirective, SvgIconComponent], 11 | template: ` 12 |
13 | Spacing and borders 14 |
15 |
16 | {{option.title}} 17 |

{{option.description}}

18 | 19 |
{{prop.title}}
20 |
21 | 22 | 23 | 26 | 27 |
28 |
29 |
30 | More utilities from the Bootstrap library 31 |
32 |
33 | `, 34 | styleUrls: ['./flex-editor.component.scss'], 35 | changeDetection: ChangeDetectionStrategy.OnPush 36 | }) 37 | export class SpacingEditorComponent implements AfterViewInit, OnChanges { 38 | @Input() config: ComponentConfig; 39 | 40 | @ViewChildren("details") details: QueryList>; 41 | 42 | componentId: string; 43 | 44 | classes: string[]; 45 | state: {[type: string]: {index: number, direction: string|undefined, magnitude: string|undefined}}; 46 | 47 | directions = { 48 | name: "direction" as const, 49 | title: $localize `Direction`, 50 | options: [ 51 | {description: $localize `All around`, code: "", icon: "direction-left-right-top-bottom"}, 52 | {description: $localize `On the left side`, code: "s", icon: "direction-left"}, 53 | {description: $localize `On the right side`, code: "e", icon: "direction-right"}, 54 | {description: $localize `On the top side`, code: "t", icon: "direction-top"}, 55 | {description: $localize `On the bottom side`, code: "b", icon: "direction-bottom"}, 56 | {description: $localize `On the left and right`, code: "x", icon: "direction-left-right"}, 57 | {description: $localize `On the top and bottom`, code: "y", icon: "direction-top-bottom"} 58 | ] 59 | }; 60 | 61 | magnitudes = { 62 | name: "magnitude" as const, 63 | title: $localize `Space`, 64 | options: [ 65 | {description: $localize `None`, code: "0", icon: "no-space"}, 66 | {description: $localize `Extra Small`, code: "1", icon: "space-xs"}, 67 | {description: $localize `Small`, code: "2", icon: "space-s"}, 68 | {description: $localize `Medium`, code: "3", icon: "space-m"}, 69 | {description: $localize `Large`, code: "4", icon: "space-l"}, 70 | {description: $localize `Extra Large`, code: "5", icon: "space-xl"} 71 | ] 72 | }; 73 | 74 | borderDirections = { 75 | name: "direction" as const, 76 | title: $localize `Direction`, 77 | options: [ 78 | {description: $localize `All around`, code: "", icon: "direction-left-right-top-bottom"}, 79 | {description: $localize `On the left side`, code: "-start", icon: "direction-left"}, 80 | {description: $localize `On the right side`, code: "-end", icon: "direction-right"}, 81 | {description: $localize `On the top side`, code: "-top", icon: "direction-top"}, 82 | {description: $localize `On the bottom side`, code: "-bottom", icon: "direction-bottom"}, 83 | ] 84 | } 85 | 86 | marginMagnitudes = { 87 | ...this.magnitudes, 88 | options: [ 89 | ...this.magnitudes.options, 90 | {description: $localize `Auto`, code: "auto", icon: "auto"} 91 | ] 92 | }; 93 | 94 | options = [ 95 | {name: "m", title: $localize `Margin`, description: $localize `Margin is the space around the component`, properties: [this.directions, this.marginMagnitudes]}, 96 | {name: "p", title: $localize `Padding`, description: $localize `Padding is the space within the component`, properties: [this.directions, this.magnitudes]}, 97 | {name: "border", title: $localize `Border`, description: $localize `Border around the component`, properties: [this.borderDirections, this.magnitudes]} 98 | ]; 99 | 100 | 101 | constructor( 102 | public configService: ConfigService, 103 | public renderer: Renderer2, 104 | public cdRef: ChangeDetectorRef 105 | ){} 106 | 107 | ngOnChanges(changes: SimpleChanges): void { 108 | if (changes.config) { 109 | this.updateState(); 110 | if(this.config.id !== this.componentId) { 111 | this.componentId = this.config.id; 112 | this.updateDetails(); // Update the
when a new component is open 113 | } 114 | } 115 | } 116 | 117 | /** 118 | * Initialize the state of the elements upon ViewInit 119 | * (the first call to updateDetails() from ngOnChanges() does not 120 | * yet have access to these elements) 121 | */ 122 | ngAfterViewInit(): void { 123 | this.updateDetails(); 124 | } 125 | 126 | /** 127 | * Take the input configuration (config.classes) and parse the content 128 | * to feed the state object (which determines the state of each option). 129 | */ 130 | updateState() { 131 | this.classes = this.config.classes?.split(' ') || []; 132 | this.state = {}; 133 | for(let index=0; index o.code)) { 152 | if(c === `border${direction}`) { 153 | this.state[`border${direction}`] = {index, direction, magnitude: undefined}; 154 | } 155 | } 156 | } 157 | } 158 | 159 | /** 160 | * This method opens or closes the elements, depending on the classes 161 | * contained in the configuration 162 | */ 163 | updateDetails() { 164 | if(this.details) { 165 | const props = Object.keys(this.state); 166 | for(let detail of this.details) { 167 | let open: boolean = true; 168 | switch(detail.nativeElement.id) { 169 | case "spacing-options": open = props.length > 0; break; 170 | case "m": open = props.includes('m'); break; 171 | case "p": open = props.includes('p'); break; 172 | case "border": open = !!props.find(p => p.startsWith("border")); break; 173 | } 174 | if(open) { 175 | this.renderer.setAttribute(detail.nativeElement, "open", ""); 176 | } 177 | else { 178 | this.renderer.removeAttribute(detail.nativeElement, "open") 179 | } 180 | } 181 | this.cdRef.detectChanges(); 182 | } 183 | } 184 | 185 | /** 186 | * Returns the activation state of a given property. 187 | * This function is called by the template to set the "checked" state 188 | * of each radio button. 189 | */ 190 | isChecked(type: 'p'|'m'|'border'|string, prop: 'direction'|'magnitude', code: string): boolean { 191 | if(type === 'border') { 192 | type = prop === 'magnitude'? `${type}-${prop}` : `${type}${code}`; 193 | } 194 | return this.state[type]?.[prop] === code; 195 | } 196 | 197 | /** 198 | * Toggles the state of a given property. 199 | * This function is called by the template upon a click on a radio button. 200 | */ 201 | toggle(type: 'p'|'m'|'border'|string, prop: 'direction'|'magnitude', code: string) { 202 | if(type === 'border') { 203 | type = prop === 'magnitude'? `${type}-${prop}` : `${type}${code}`; 204 | } 205 | const match = this.state[type]; 206 | if(match) { 207 | const currentVal = match[prop]; 208 | if(currentVal === code) { 209 | this.classes[match.index] = ""; 210 | delete this.state[type]; 211 | } 212 | else { 213 | match[prop] = code; 214 | } 215 | } 216 | else { 217 | this.state[type] = {index: this.classes.length, direction: '', magnitude: '0'}; 218 | this.state[type][prop] = code; 219 | } 220 | 221 | this.updateClasses(); 222 | } 223 | 224 | /** 225 | * Update the class list and propagate the update to the config service 226 | */ 227 | updateClasses() { 228 | for(const [type,match] of Object.entries(this.state)) { 229 | if(type.startsWith('border')) { 230 | this.classes[match.index] = type === 'border-magnitude' ? `border-${match.magnitude}` : `border${match.direction}`; 231 | } 232 | else { 233 | this.classes[match.index] = `${type}${match.direction}-${match.magnitude}`; 234 | } 235 | } 236 | this.config.classes = this.classes.filter(c => c).join(' '); 237 | this.configService.updateConfig(this.config); 238 | } 239 | 240 | } 241 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/image-selector/img-selector.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; 2 | import { FormsModule } from "@angular/forms"; 3 | 4 | import { ConfiguratorContext } from "../configurator.models"; 5 | import { NgModelChangeDebouncedDirective } from "../../utils"; 6 | import { CommonModule } from "@angular/common"; 7 | 8 | @Component({ 9 | selector: "uib-image-selector", 10 | imports: [CommonModule, FormsModule, NgModelChangeDebouncedDirective], 11 | standalone: true, 12 | template: ` 13 |
14 | 15 | 22 | 23 | 24 |
25 |
26 | Width 27 | 32 | px 33 |
34 | 40 | 41 |
42 | Height 43 | 48 | px 49 |
50 | 56 |
57 | 58 |
59 | 60 | Preview 61 | 62 |
63 | 64 |
65 |
66 |
67 | ` 68 | }) 69 | export class ImageSelectorComponent implements OnChanges { 70 | @Input() context: ConfiguratorContext; 71 | @Input() param: string; 72 | @Input() description: string; 73 | @Input() sizeable: boolean = true; 74 | 75 | 76 | ngOnChanges(changes: SimpleChanges): void { 77 | // TODO: to remove with the next release 78 | // convert old format to the new one 79 | if (!this.context.config.images || !this.context.config.images[this.param]) { 80 | this.context.config.images = {...this.context.config.images, [this.param]: { filename: this.context.config[this.param]}} 81 | delete this.context.config[this.param]; 82 | } 83 | } 84 | 85 | onImageLoaded(event: Event) { 86 | const file = (event.target as HTMLInputElement).files?.[0]; 87 | if (file) { 88 | const reader = new FileReader(); 89 | reader.onload = () => { 90 | if (reader.result) { 91 | this.context.config.images[this.param].filename = reader.result; 92 | this.context.configChanged(); 93 | } 94 | } 95 | reader.readAsDataURL(file); 96 | } 97 | } 98 | 99 | onImageDimensionChange(value: string, size: 'width' | 'height') { 100 | if (value.trim().length === 0) { 101 | this.context.config.images[this.param][size] = undefined; 102 | } 103 | this.context.configChanged(); 104 | } 105 | 106 | onChange(value: string) { 107 | this.context.configChanged(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; 2 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/multi-selector/multi-selector.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 17 | 18 | 19 | 20 |
21 | 27 | 30 |
31 |
32 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/multi-selector/multi-selector.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from "@angular/common"; 2 | import { Component, forwardRef, HostBinding, Input, OnChanges, SimpleChanges } from "@angular/core"; 3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; 4 | import { DndDropEvent, DndModule } from "ngx-drag-drop"; 5 | 6 | @Component({ 7 | selector: "uib-multi-selector", 8 | imports: [CommonModule, DndModule], 9 | standalone: true, 10 | templateUrl: './multi-selector.component.html', 11 | styles: [` 12 | .dndDraggable, .dndDraggable label { 13 | cursor: grab; 14 | } 15 | [dndPlaceholderRef] { 16 | height: 1rem; 17 | background: #ccc; 18 | border: dotted 3px #999; 19 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); 20 | } 21 | :host { 22 | max-height: var(--multi-select-max-height); 23 | overflow: auto; 24 | margin-bottom: 0.5rem; 25 | } 26 | `], 27 | providers: [{ 28 | provide: NG_VALUE_ACCESSOR, 29 | useExisting: forwardRef(() => MultiSelectComponent), 30 | multi: true 31 | }], 32 | host: { 33 | class: "form-control" 34 | } 35 | }) 36 | export class MultiSelectComponent implements OnChanges, ControlValueAccessor { 37 | @Input() options: T[] | undefined; 38 | @Input() enableReorder = false; 39 | @Input() valueField?: string; 40 | @Input() displayField?: string; 41 | @Input() compareWith: (a: T, b:T) => boolean = Object.is; 42 | @Input() @HostBinding("style.--multi-select-max-height") maxHeight = "300px"; 43 | 44 | // Copy of options, so we can change the order without affecting the original list of options 45 | // If the options change, the order is reinitialized. 46 | _options: T[] | undefined; 47 | _optionsSelected: (T|null)[] | undefined; 48 | 49 | _values: T[] | undefined; 50 | 51 | static idCpt = 0; 52 | selectId: string; 53 | 54 | constructor(){ 55 | this.selectId = `select-${MultiSelectComponent.idCpt++}`; 56 | } 57 | 58 | ngOnChanges(changes: SimpleChanges): void { 59 | if(changes.options) { 60 | this._options = undefined; 61 | this.updateOptions(); 62 | } 63 | } 64 | 65 | updateOptions() { 66 | if(this.options) { 67 | this._options = [...this.options]; 68 | if(this.enableReorder && this._values) { 69 | this._optionsSelected = this._values.map(val => { 70 | const i = this._options!.findIndex(o => this.compareWith(val, this.getValue(o))); // For each value, find its corresponding option 71 | return i !== -1? this._options!.splice(i, 1)[0] : null; // i === -1 can happen with incomplete list of metadata. null allows the Drag & Drop directive to have the right number of items 72 | }); 73 | } 74 | } 75 | } 76 | 77 | getValue(option: T) { 78 | return this.valueField? option[this.valueField] : option; 79 | } 80 | 81 | valueIndex(value: T) { 82 | return this._values?.findIndex(v => this.compareWith(v, value)) ?? -1; 83 | } 84 | 85 | isChecked(option: T): boolean { 86 | if(!this._values) return false; 87 | const value = this.getValue(option); 88 | return this.valueIndex(value) !== -1; 89 | } 90 | 91 | onChecked(option: T, checked: boolean) { 92 | if(!this._values) { 93 | this._values = []; 94 | } 95 | const value = this.getValue(option); 96 | if(checked) { 97 | this._values.push(value); 98 | } 99 | else { 100 | this._values.splice(this.valueIndex(value), 1); 101 | } 102 | this.triggerChange(); 103 | } 104 | 105 | onDrop(event: DndDropEvent) { 106 | const oldIdx = event.data; 107 | const newIdx = event.index; 108 | if(this._values && typeof oldIdx === 'number' && typeof newIdx === 'number' && oldIdx !== newIdx) { 109 | const [obj] = this._values.splice(oldIdx, 1); 110 | this._values?.splice(newIdx {}; 121 | onTouched = () => {}; 122 | 123 | writeValue(obj: T[]): void { 124 | this._values = obj; 125 | this.updateOptions(); 126 | } 127 | 128 | registerOnChange(fn: any): void { 129 | this.onChange = fn; 130 | } 131 | 132 | registerOnTouched(fn: any): void { 133 | this.onTouched = fn; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/palette/palette.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 |
Drag & drop a new component
7 |
8 | Drag & drop an item from this palette into the UI to create and configure a new component. 9 |
10 | 13 | 14 |
15 |
16 | 17 |
18 | 19 |
Drag & drop an existing component
20 |
21 | Drag & drop an item from this palette into the UI to insert a component whose configuration already exists. 22 | 23 | 24 |
25 | 28 | 29 |
30 |
31 |
32 | 33 | 34 |
44 | 45 | 46 | {{item.display || item.type}} 47 | 56 |
57 |
58 | 59 | 60 | 61 |
62 |
63 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/palette/palette.component.scss: -------------------------------------------------------------------------------- 1 | small, span { 2 | cursor: default; 3 | } 4 | 5 | .palette-item { 6 | display: inline-block; 7 | border: 1px solid grey; 8 | padding: 0.25rem 0.5rem; 9 | border-radius: 3px; 10 | background: rgb(0,0,0,0.1); 11 | cursor: grab; 12 | margin-right: 5px; 13 | margin-bottom: 5px; 14 | } 15 | 16 | .palette-item .btn-close { 17 | font-size: 0.7em; 18 | } 19 | 20 | .palette-item .grip { 21 | position: relative; 22 | top: -2px; 23 | color: #7c7c7c; 24 | margin-right: 3px; 25 | } 26 | 27 | .palette-item.uib-unused { 28 | border-style: dashed; 29 | } 30 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/palette/palette.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core'; 2 | import { Observable, of, Subject, Subscription } from 'rxjs'; 3 | import { Configurable } from '../../configurable/configurable.service'; 4 | import { ComponentConfig, ConfigService } from '../../configuration'; 5 | import { ComponentCreator, DragDropService } from '../../dynamic-views/drag-drop.service'; 6 | import { ModalComponent, SvgIconComponent, TemplateNameDirective, TooltipDirective } from '../../utils'; 7 | import { PaletteOptions } from '../configurator.models'; 8 | import { CommonModule } from '@angular/common'; 9 | import { DndModule } from 'ngx-drag-drop'; 10 | 11 | declare interface PaletteItem extends ComponentCreator { 12 | type: string; 13 | display?: string; 14 | iconClass?: string; 15 | title?: string; 16 | removable?: boolean; 17 | createConfig: (id: string, creator?: ComponentCreator) => Observable; 18 | } 19 | 20 | declare interface ConfigModal { 21 | configurator: TemplateNameDirective; 22 | config: ComponentConfig; 23 | configChanged: () => void; 24 | title: string; 25 | close: Subject; 26 | } 27 | 28 | export const defaultPaletteOptions: PaletteOptions = { 29 | enableSubcontainers: true, 30 | enableRawHtml: true, 31 | rawHtmlPlaceholder: "

Edit me

", 32 | showStandardPalette: true, 33 | showExistingPalette: true 34 | }; 35 | 36 | @Component({ 37 | selector: 'uib-palette', 38 | standalone: true, 39 | imports: [CommonModule, DndModule, TooltipDirective, SvgIconComponent, ModalComponent], 40 | templateUrl: './palette.component.html', 41 | styleUrls: ['./palette.component.scss'], 42 | changeDetection: ChangeDetectionStrategy.OnPush 43 | }) 44 | export class PaletteComponent implements OnInit, OnChanges, OnDestroy { 45 | @Input() context: Configurable; 46 | @Input() configurators: Record = {}; 47 | @Input() options = defaultPaletteOptions; 48 | 49 | standardPalette: PaletteItem[]; 50 | existingPalette: PaletteItem[]; 51 | 52 | modal?: ConfigModal; 53 | 54 | constructor( 55 | public dragDropService: DragDropService, 56 | public configService: ConfigService, 57 | public cdRef: ChangeDetectorRef 58 | ) {} 59 | 60 | ngOnInit() { 61 | // The palette of existing components is constructed from the complete configuration 62 | this.sub = this.configService.watchAllConfig() 63 | .subscribe(configs => { 64 | this.generateExistingPalette(configs) 65 | this.cdRef.markForCheck(); 66 | }); 67 | } 68 | 69 | ngOnChanges() { 70 | // Initialize options with default, then custom 71 | this.options = Object.assign({}, defaultPaletteOptions, this.options); 72 | this.generateAutoPalette(); 73 | // The existing palette must be update when the standard palette changes 74 | this.generateExistingPalette(this.configService.getAllConfig()); 75 | } 76 | 77 | sub: Subscription; 78 | ngOnDestroy(): void { 79 | this.sub.unsubscribe(); 80 | } 81 | 82 | generateAutoPalette() { 83 | this.standardPalette = []; 84 | if(!this.options.showStandardPalette) { 85 | return; 86 | } 87 | if (this.options.enableSubcontainers) { 88 | this.standardPalette.push({ 89 | type: '_container', 90 | display: $localize `Container`, 91 | title: $localize `A component to arrange various sub-components`, 92 | createConfig: (id: string) => of({ type: '_container', id, items: [] }), 93 | }); 94 | } 95 | if (this.options.enableRawHtml) { 96 | this.standardPalette.push({ 97 | type: '_raw-html', 98 | display: $localize `Raw HTML`, 99 | title: $localize `A component to write HTML freely`, 100 | createConfig: (id: string) => of({ type: '_raw-html', id, rawHtml: this.options.rawHtmlPlaceholder}) 101 | }) 102 | } 103 | if (this.context.templates) { 104 | Object.keys(this.context.templates).forEach((type) => { 105 | let template = this.context.templates![type]; 106 | this.standardPalette.push({ 107 | type, 108 | display: template.display || type, 109 | iconClass: template.iconClass, 110 | title: template.description, 111 | createConfig: (id: string) => this.openModal(id, type, this.configurators[type]), 112 | }); 113 | }); 114 | } 115 | } 116 | 117 | generateExistingPalette(configs: ComponentConfig[]) { 118 | if(!this.options.showExistingPalette) { 119 | this.existingPalette = []; 120 | return; 121 | } 122 | this.existingPalette = configs.filter(c => 123 | // Add any non-container config whose type is compatible with the standard palette 124 | this.standardPalette 125 | .find(p => p.type !== '_container' && p.type === c.type)) 126 | .map(c => ({ 127 | type: c.type, 128 | display: c.id, 129 | title: $localize `Type: ${this.context.templates?.[c.type]?.display || c.type}`, 130 | removable: !this.configService.isUsed(c.id), 131 | createConfig: _ => of(c) // The config already exists 132 | }) 133 | ) 134 | } 135 | 136 | onDndStart(item: PaletteItem) { 137 | this.dragDropService.draggedCreator = item; 138 | } 139 | 140 | onDndEnd() { 141 | this.dragDropService.draggedCreator = undefined; 142 | } 143 | 144 | openModal(id: string, type: string, configurator?: TemplateNameDirective): Observable { 145 | const config = {type, id}; 146 | if(configurator) { 147 | this.modal = { 148 | configurator, 149 | config, 150 | configChanged: () => {}, // do nothing when the configurator changes the config (before user presses 'OK') 151 | title: $localize `Create new ${type} component`, 152 | close: new Subject() 153 | } 154 | return this.modal.close; 155 | } 156 | return of(config); 157 | } 158 | 159 | onModalClose(success: boolean) { 160 | if(this.modal?.close) { 161 | this.modal.close.next(success? this.modal.config : undefined); 162 | this.modal.close.complete(); 163 | this.modal = undefined; 164 | } 165 | } 166 | 167 | removeItem(item: PaletteItem) { 168 | console.log("remove", item); 169 | this.configService.removeConfig(item.display!); // The display is the component id 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './editors'; 2 | export * from './palette/palette.component'; 3 | export * from './toolbar/toolbar.component'; 4 | export * from './tree/tree.component'; 5 | export * from './checkbox/checkbox.component'; 6 | export * from './color-picker/color-picker.component'; 7 | export * from './image-selector/img-selector.component'; 8 | export * from './multi-selector/multi-selector.component'; 9 | 10 | export * from './configurator.component'; 11 | export * from './configurator.models'; 12 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/toolbar/toolbar.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 7 | 10 | 11 | 14 |
15 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/toolbar/toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from "@angular/core"; 2 | 3 | import { CommonModule } from "@angular/common"; 4 | import { ConfigurableService } from "../../configurable"; 5 | import { ConfigService } from "../../configuration"; 6 | import { TooltipDirective } from "../../utils"; 7 | import { SvgIconComponent } from "../../utils/svg-icon/svg-icon.component"; 8 | 9 | @Component({ 10 | selector: 'uib-toolbar', 11 | standalone: true, 12 | imports: [CommonModule, SvgIconComponent, TooltipDirective], 13 | templateUrl: './toolbar.component.html', 14 | styles: [ 15 | ` 16 | .uib-toolbar .btn.disabled { 17 | /* allow tooltip on disabled buttons */ 18 | pointer-events: visible; 19 | /* but display a simple arrow cursor */ 20 | cursor: default; 21 | } 22 | 23 | .uib-toolbar .btn svg-icon { 24 | display: flex; 25 | align-items: center; 26 | width: 22px; 27 | height: 25px; 28 | } 29 | .uib-toolbar-anim { 30 | transition: ease .3s; 31 | } 32 | ` 33 | ], 34 | changeDetection: ChangeDetectionStrategy.OnPush 35 | }) 36 | export class ToolbarComponent { 37 | 38 | constructor( 39 | public configService: ConfigService, 40 | public configurableService: ConfigurableService 41 | ) {} 42 | 43 | toggleEditor() { 44 | this.configurableService.toggleEditor(); 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /projects/lib/src/configurator/tree/tree.component.css: -------------------------------------------------------------------------------- 1 | .tree, 2 | .section ul { 3 | list-style: none; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | .tree li { 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: flex-start; 12 | align-items: flex-start; 13 | 14 | list-style-type: none; 15 | margin: 10px; 16 | position: relative; 17 | width: 100%; 18 | } 19 | 20 | .tree li:not(.section) { 21 | max-width: max-content; 22 | } 23 | 24 | 25 | .tree li::before { 26 | content: ""; 27 | position: absolute; 28 | top: -9px; 29 | left: -20px; 30 | border-left: 1px solid #ccc; 31 | border-bottom: 1px solid #ccc; 32 | border-radius: 0 0 0 0px; 33 | width: 20px; 34 | height: 22px; 35 | } 36 | 37 | .tree li::after { 38 | position: absolute; 39 | content: ""; 40 | top: 12px; 41 | left: -20px; 42 | border-left: 1px solid #ccc; 43 | border-top: 1px solid #ccc; 44 | border-radius: 0px 0 0 0; 45 | width: 20px; 46 | height: 100%; 47 | } 48 | 49 | .tree li:last-child::after { 50 | display: none; 51 | } 52 | 53 | .tree li:last-child:before { 54 | border-radius: 0 0 0 5px; 55 | } 56 | 57 | ul.tree > li:first-child::before { 58 | display: none; 59 | } 60 | 61 | ul.tree > li:first-child::after { 62 | border-radius: 5px 0 0 0; 63 | } 64 | 65 | .tree li a { 66 | border: 1px #ccc solid; 67 | border-radius: 5px; 68 | padding: 2px 5px; 69 | text-decoration: none; 70 | } 71 | 72 | .tree li a.selected { 73 | box-shadow: 0 0 0 3px red; 74 | z-index: 1; 75 | } 76 | 77 | .tree li a:hover, 78 | .tree li a:hover + ul li a, 79 | .tree li a:focus, 80 | .tree li a:focus + ul li a { 81 | background: #ccc; 82 | color: #000; 83 | border: 1px solid #000; 84 | cursor: pointer; 85 | } 86 | 87 | .tree li a:hover + ul li::after, 88 | .tree li a:focus + ul li::after, 89 | .tree li a:hover + ul li::before, 90 | .tree li a:focus + ul li::before .tree li a:hover + ul::before, 91 | .tree li a:focus + ul::before .tree li a:hover + ul ul::before, 92 | .tree li a:focus + ul ul::before { 93 | border-color: #000; /*connector color on hover*/ 94 | } 95 | 96 | .tree li svg-icon { 97 | cursor: pointer; 98 | } 99 | 100 | .section ul { 101 | transition: opacity 0.3s linear; 102 | height: 0; 103 | transform: translate(9999px); 104 | opacity: 0; 105 | } 106 | .section input:checked ~ ul { 107 | transform: translate(0); 108 | opacity: 1; 109 | height: auto; 110 | } 111 | 112 | .section input[type="checkbox"] { 113 | display: none; 114 | } 115 | 116 | .section { 117 | position: relative; 118 | padding-left: 35px; 119 | } 120 | 121 | .section label + a { 122 | margin-left: -35px; 123 | padding-left: 22px; 124 | text-decoration: none; 125 | color: #000; 126 | } 127 | 128 | .section label:before { 129 | content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%280,0,0,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e"); 130 | position: absolute; 131 | top: 4px; 132 | left: 2px; 133 | text-align: center; 134 | display: inline-block; 135 | transition: transform 0.5s; 136 | z-index: 2; 137 | user-select: none; 138 | cursor: pointer; 139 | } 140 | 141 | .section input:checked ~ label:before { 142 | transform: rotate(90deg); 143 | } 144 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/tree/tree.component.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | 8 | 9 |
    10 |
  • 11 | 12 |
  • 13 |
14 |
15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 42 | -------------------------------------------------------------------------------- /projects/lib/src/configurator/tree/tree.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; 2 | import { ConfigurableService} from '../../configurable'; 3 | import { ComponentConfig, ConfigService } from '../../configuration'; 4 | import { CommonModule } from '@angular/common'; 5 | import { SvgIconComponent } from "../../utils/svg-icon/svg-icon.component"; 6 | import { ModalComponent } from '../../utils'; 7 | import { FormsModule } from '@angular/forms'; 8 | 9 | @Component({ 10 | selector: 'uib-tree', 11 | standalone: true, 12 | templateUrl: './tree.component.html', 13 | styleUrls: ['tree.component.css'], 14 | imports: [CommonModule, SvgIconComponent, ModalComponent, FormsModule] 15 | }) 16 | export class TreeComponent implements OnChanges { 17 | @Input() configuration: ComponentConfig[]; 18 | 19 | root: Partial[]; 20 | hoveredId?: string; 21 | configToEdit?: {id: string, display: string}; 22 | displayName?: string; 23 | 24 | private configurationMap: Map>; 25 | 26 | constructor(public configurableService: ConfigurableService, 27 | public configService: ConfigService) { } 28 | 29 | ngOnChanges(changes: SimpleChanges) { 30 | // set our Map object, 31 | this.configurationMap = new Map(); 32 | this.configuration.forEach((el) => this.configurationMap.set(el.id, el)); 33 | 34 | // set children parent_id and order 35 | this.configuration 36 | .filter((parent) => parent.items && parent.items.length > 0) 37 | .forEach((parent) => 38 | parent.items.forEach((id: string, index: number) => { 39 | // a child could have multiple parents 40 | // get child's parents 41 | const {parents = [], orders = {}} = this.configurationMap.get(id) || {}; 42 | this.configurationMap.set(id, {...this.configurationMap.get(id), id: id, parents: [...parents, parent.id], orders: {...orders, [parent.id]: index }} ) 43 | }) 44 | ); 45 | 46 | // items without parent_id (root level) 47 | this.root = [...this.configurationMap.values()].filter((el) => el.parents === undefined); 48 | } 49 | 50 | /** 51 | * Given an id, return all the children of that id. 52 | * @param {string} id - string - The id of the component. 53 | * @returns The children of the component with the given id. 54 | */ 55 | children(id: string): Partial[] { 56 | return [...this.configurationMap.values()].filter((el) => el.parents ? el.parents.includes(id) : false).sort((a, b) => a.orders[id] - b.orders[id]); 57 | } 58 | 59 | select(id: string) { 60 | const el = this.configurableService.configurableDirectiveMap.get(id); 61 | el?.click(new MouseEvent("click")); 62 | el?.nativeElement.scrollIntoView({behavior: 'smooth', inline: 'nearest', block: 'center'}); 63 | } 64 | 65 | hover(id: string | undefined) { 66 | this.hoveredId = id; // used to display and hide the edit icons 67 | const hoveredId = this.configurableService.hoveredId; 68 | if (hoveredId) { 69 | const prev = this.configurableService.configurableDirectiveMap.get(hoveredId); 70 | prev?.removeHighlight(); 71 | } 72 | 73 | this.configurableService.hoveredId = id; 74 | if (id) { 75 | const el = this.configurableService.configurableDirectiveMap.get(id); 76 | el?.addHighlight(); 77 | 78 | el?.nativeElement.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'center' }); 79 | } 80 | } 81 | 82 | editConfigDisplayName(context: {id: string, display: string}) { 83 | this.configToEdit = context; 84 | this.displayName = context.display ? String(context.display) : undefined; 85 | } 86 | 87 | onModalClose(validated: boolean) { 88 | if (validated) { 89 | const config = this.configService.getConfig(this.configToEdit!.id); 90 | config.display = this.displayName; 91 | this.configService.updateConfig(config); 92 | } 93 | this.configToEdit = undefined; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/drag-drop.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { 4 | ComponentConfig, 5 | ConfigService, 6 | ContainerConfig, 7 | } from '../configuration'; 8 | import { ToastAction, ToastService } from '../utils'; 9 | 10 | export interface ContainerIndex { 11 | container: string; 12 | index: number; 13 | } 14 | 15 | export interface ComponentCreator { 16 | type: string; 17 | createConfig: (id: string, creator?: ComponentCreator) => Observable; 18 | } 19 | 20 | @Injectable({ providedIn: 'root' }) 21 | export class DragDropService { 22 | 23 | constructor( 24 | public configService: ConfigService, 25 | public toastService: ToastService 26 | ) {} 27 | 28 | draggedCreator?: ComponentCreator; 29 | 30 | undoAction: ToastAction = { 31 | text: $localize `Undo`, 32 | hideToast: true, 33 | action: () => this.configService.undo() 34 | } 35 | 36 | public handleDrop( 37 | containerId: string, 38 | index: number, 39 | dropped: ContainerIndex | string 40 | ) { 41 | const container = this.configService.getContainer(containerId); 42 | // Drop a component from a container to another container 43 | if ((dropped as ContainerIndex).container) { 44 | dropped = dropped as ContainerIndex; 45 | const og = this.configService.getContainer(dropped.container); 46 | if (dropped.container === containerId) { 47 | this.moveWithin(og, dropped.index, index); 48 | } else { 49 | this.moveBetween(container, index, og, dropped.index); 50 | } 51 | } 52 | // Drag a component creator (from a palette) 53 | else if (this.draggedCreator?.type === dropped) { 54 | const newId = this.configService.generateId(dropped); 55 | this.draggedCreator 56 | .createConfig(newId, this.draggedCreator) 57 | .subscribe((config) => this.insertNew(container, index, config)); 58 | } 59 | else { 60 | console.error("Unexpected dropped item:", dropped); 61 | } 62 | } 63 | 64 | public handleCancel( 65 | index: number, 66 | containerId: string 67 | ) { 68 | const container = this.configService.getContainer(containerId); 69 | const config = container.items.splice(index, 1); 70 | this.configService.updateConfig([container]); 71 | this.toastService.show( 72 | $localize `Component '${config[0]}' removed`, 73 | "warning text-dark", 74 | [this.undoAction] 75 | ); 76 | } 77 | 78 | private insertNew( 79 | container: ContainerConfig, 80 | index: number, 81 | component: ComponentConfig|undefined 82 | ) { 83 | if(component) { 84 | container.items.splice(index, 0, component.id); 85 | this.configService.updateConfig([container, component]); // addEntities might be needed 86 | } 87 | } 88 | 89 | private moveBetween( 90 | container: ContainerConfig, 91 | index: number, 92 | ogContainer: ContainerConfig, 93 | ogIndex: number 94 | ) { 95 | let item = ogContainer.items.splice(ogIndex, 1); 96 | container.items.splice(index, 0, item[0]); 97 | this.configService.updateConfig([ogContainer, container]); 98 | } 99 | 100 | private moveWithin( 101 | container: ContainerConfig, 102 | oldIndex: number, 103 | newIndex: number 104 | ) { 105 | if (oldIndex !== newIndex && oldIndex !== newIndex - 1) { 106 | let item = container.items.splice(oldIndex, 1); 107 | if (newIndex > oldIndex) { 108 | newIndex--; 109 | } 110 | container.items.splice(newIndex, 0, item[0]); 111 | this.configService.updateConfig(container); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; 2 | -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/item/item.component.html: -------------------------------------------------------------------------------- 1 |
4 |
5 | 6 | 11 | 12 | 13 |
18 |
19 | 20 | 21 | 22 |
30 | 31 |
32 | 33 |
50 | 51 |
52 | 53 |
54 | -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/item/item.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | ChangeDetectorRef, 4 | Component, 5 | ElementRef, 6 | HostBinding, 7 | Input, 8 | OnChanges, 9 | OnDestroy, 10 | OnInit, 11 | SimpleChanges, 12 | } from '@angular/core'; 13 | import { CommonModule } from '@angular/common'; 14 | import { Subscription } from 'rxjs'; 15 | 16 | import { ConditionsService } from '../../conditions/conditions.service'; 17 | import { ComponentConfig, ConfigService } from '../../configuration'; 18 | import { ZoneContextService } from '../zone/zone-context.service'; 19 | import { ContainerIndex, DragDropService } from '../drag-drop.service'; 20 | import { TooltipDirective } from '../../utils'; 21 | import { ConfigurableDirective } from '../../configurable'; 22 | import { DndDropEvent, DndModule } from 'ngx-drag-drop'; 23 | 24 | @Component({ 25 | selector: '[uib-item]', 26 | standalone: true, 27 | imports: [CommonModule, TooltipDirective, ConfigurableDirective, DndModule], 28 | templateUrl: './item.component.html', 29 | changeDetection: ChangeDetectionStrategy.OnPush 30 | }) 31 | export class ItemComponent implements OnInit, OnChanges, OnDestroy { 32 | /** 33 | * id's element 34 | */ 35 | @Input('uib-item') id: string; 36 | @Input() parentId: string; 37 | 38 | /** 39 | * index of element displayed when data is an Array 40 | */ 41 | @Input() dataIndex?: number; 42 | 43 | /** 44 | * Is the component configurable 45 | */ 46 | @Input() configurable: boolean = true; 47 | 48 | @HostBinding('class') 49 | classes?: string; 50 | 51 | 52 | // zone's conext informations 53 | /** 54 | * zone's name/id 55 | */ 56 | zone: string; 57 | 58 | /** 59 | * used to store the configuration object for the component. 60 | */ 61 | config: ComponentConfig; 62 | /** 63 | * used to determine if the component should be displayed or not. 64 | */ 65 | condition = true; 66 | _data: any; 67 | 68 | private subscription = new Subscription(); 69 | 70 | /** 71 | * used to determine if the flex direction is horizontal or not. 72 | * Always `false` when the component's type is not `_container` 73 | */ 74 | isHorizontal: boolean = false; 75 | 76 | constructor( 77 | public zoneRef: ZoneContextService, 78 | public configService: ConfigService, 79 | public conditionsService: ConditionsService, 80 | public dragDropService: DragDropService, 81 | public cdr: ChangeDetectorRef, 82 | public el: ElementRef 83 | ) { 84 | // retrieve all zone's available informations 85 | this.zone = this.zoneRef.id; 86 | 87 | this.subscription.add( 88 | this.zoneRef.changes$.subscribe(({ data, conditionsData }) => { 89 | this.updateData(); 90 | this.updateCondition(); 91 | }) 92 | ); 93 | } 94 | 95 | ngOnChanges(changes: SimpleChanges): void { 96 | if(changes.dataIndex) { 97 | this.updateData(); 98 | this.updateCondition(); 99 | } 100 | } 101 | 102 | ngOnInit() { 103 | const configChanges$ = this.configService.watchConfig(this.id).subscribe((config) => this.updateConfig(config)); 104 | const allConfigChanges$ = this.configService.watchAllConfig().subscribe(() => this.cdr.markForCheck()); 105 | 106 | this.subscription.add(configChanges$); 107 | this.subscription.add(allConfigChanges$); 108 | } 109 | 110 | ngOnDestroy() { 111 | this.subscription.unsubscribe(); 112 | } 113 | 114 | // updates 115 | 116 | /** 117 | * It updates the component's configuration and condition, and sets the classes 118 | * for the component 119 | * @param {ComponentConfig} config - ComponentConfig - the configuration object 120 | * for the component 121 | */ 122 | private updateConfig(config: ComponentConfig) { 123 | this.config = config; 124 | this.updateCondition(); 125 | this.classes = this.config.classes || ''; 126 | if (config.type === '_container') { 127 | this.classes += ' uib-container'; 128 | if (this.configurable) { 129 | this.classes += ' uib-dropzone-content'; 130 | } 131 | } 132 | if(!this.condition && !this.configurable) { 133 | this.classes += ' d-none'; // hide component unless in edit mode 134 | } 135 | this.isHorizontal = this.horizontal(); 136 | } 137 | 138 | /** 139 | * If the dataIndex property is undefined, then the data property is assigned to 140 | * the `_data` property. 141 | * 142 | * Otherwise, the data property is indexed by the dataIndex 143 | * property and the result is assigned to the _data property 144 | */ 145 | private updateData() { 146 | this._data = typeof this.dataIndex === 'undefined'? this.zoneRef.data : this.zoneRef.data[this.dataIndex]; 147 | } 148 | 149 | /** 150 | * If the condition is not null, then check the condition and update the 151 | * condition variable 152 | */ 153 | private updateCondition() { 154 | this.condition = this.conditionsService.check(this.config?.condition, this.zoneRef.conditionsData, this._data); 155 | } 156 | 157 | // Drag & Drop 158 | 159 | onDndDrop(event: DndDropEvent) { 160 | const dropped: string | ContainerIndex = (typeof event.data === 'string') 161 | ? event.data 162 | : { container: event.data.container, index: event.data.index } 163 | 164 | if(typeof event.index === 'number') { 165 | this.dragDropService.handleDrop(this.id, event.index, dropped); 166 | } 167 | } 168 | 169 | onDndCanceled(item: string, index: number) { 170 | console.log('cancelled', item, this.id); 171 | this.dragDropService.handleCancel(index, this.id); 172 | } 173 | 174 | /** 175 | * * If the element has a class of flex-column, it's not horizontal. 176 | * * If it has a class of d-flex or uib-dropzone, it's horizontal. 177 | * * If it has a style of display: flex, it's horizontal. 178 | * 179 | * Otherwise, it's not horizontal 180 | * @returns A boolean value. 181 | */ 182 | private horizontal(): boolean { 183 | if(this.config.classes?.includes('flex-column')) { 184 | return false; 185 | } 186 | if (this.config.classes?.includes('d-flex') || this.config.classes?.includes('uib-dropzone') || this.el.nativeElement.style.display === "flex") { 187 | return true; 188 | } 189 | return false; 190 | } 191 | 192 | getItemTooltip(id: string): string { 193 | const config = this.configService.getConfig(id); 194 | return config.display || id; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './drag-drop.service'; 2 | export * from './item/item.component'; 3 | export * from './zone/zone.component'; 4 | export * from './zone/zone-context.service'; 5 | -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/zone/zone-context.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { Subject } from "rxjs"; 3 | import { TemplateNameDirective } from "../../utils/directive/template-name.directive"; 4 | 5 | @Injectable() 6 | export class ZoneContextService { 7 | /** 8 | * zone's id 9 | */ 10 | id: string; 11 | /** 12 | * additional template's data. If data is an array, an *ngFor will be done using 13 | * each data's items 14 | */ 15 | data?: any; 16 | /** 17 | * conditional display rules 18 | */ 19 | conditionsData?: Record; 20 | 21 | /** 22 | * TemplateNameDirective's collection 23 | */ 24 | templates: Record = {}; 25 | 26 | /** 27 | * When data or conditionsData change, emit changes 28 | */ 29 | changes$ = new Subject<{data: unknown, conditionsData: Record | undefined}>(); 30 | 31 | update(state:{data: unknown, conditionsData: Record | undefined}) { 32 | this.data = state.data; 33 | this.conditionsData = state.conditionsData; 34 | 35 | this.changes$.next(state); 36 | } 37 | } -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/zone/zone.component.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
26 |
27 | 28 | 29 |
35 |
36 | 37 |
38 | -------------------------------------------------------------------------------- /projects/lib/src/dynamic-views/zone/zone.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | ChangeDetectorRef, 4 | Component, 5 | OnDestroy, 6 | OnInit, 7 | Output, 8 | EventEmitter, 9 | ContentChildren, 10 | Input, 11 | QueryList, 12 | SimpleChanges, 13 | } from '@angular/core'; 14 | import { BehaviorSubject, Subscription } from 'rxjs'; 15 | 16 | import { ConfigService } from '../../configuration/config.service'; 17 | import { ConfigurableDirective, ConfigurableService } from '../../configurable'; 18 | import { ZoneContextService } from './zone-context.service'; 19 | import { TemplateNameDirective } from '../../utils'; 20 | import { CommonModule } from '@angular/common'; 21 | import { ItemComponent } from '../public-api'; 22 | 23 | 24 | @Component({ 25 | selector: 'uib-zone', 26 | standalone: true, 27 | imports: [CommonModule, ConfigurableDirective, ItemComponent], 28 | templateUrl: './zone.component.html', 29 | changeDetection: ChangeDetectionStrategy.OnPush, 30 | providers: [ZoneContextService] 31 | }) 32 | export class ZoneComponent implements OnInit, OnDestroy { 33 | @ContentChildren(TemplateNameDirective) 34 | children: QueryList; 35 | 36 | /** 37 | * zone's id 38 | */ 39 | @Input() id: string; 40 | /** 41 | * additional template's data. If data is an array, an *ngFor will be done using 42 | * each data's items 43 | */ 44 | @Input() data?: any; 45 | /** 46 | * conditional display rules 47 | */ 48 | @Input() conditionsData?: Record; 49 | 50 | /** 51 | * Emit an event when a zone's element is clicked 52 | * sending it's index and additional data 53 | */ 54 | @Output() 55 | itemClicked = new EventEmitter<{data: any, index?: number, event: Event}>(); 56 | 57 | /** 58 | * return `true` when edit mode is enabled 59 | */ 60 | enabled$: BehaviorSubject; 61 | 62 | private subscription = new Subscription(); 63 | 64 | constructor( 65 | public readonly zoneContext: ZoneContextService, 66 | public readonly configService: ConfigService, 67 | public readonly configurableService: ConfigurableService, 68 | public readonly cdr: ChangeDetectorRef 69 | ) {} 70 | 71 | ngOnInit() { 72 | this.zoneContext.id = this.id; 73 | this.enabled$ = this.configurableService.editorEnabled$ 74 | 75 | this.subscription.add( 76 | this.configService 77 | .watchConfig(this.id) 78 | .subscribe(() => { 79 | this.cdr.markForCheck(); // necessary to apply config changes 80 | }) 81 | ); 82 | } 83 | 84 | 85 | ngOnDestroy() { 86 | this.subscription.unsubscribe(); 87 | } 88 | 89 | ngOnChanges(changes: SimpleChanges): void { 90 | this.zoneContext.update({data: this.data, conditionsData: this.conditionsData}); 91 | } 92 | 93 | ngAfterContentInit() { 94 | this.children.forEach( 95 | (template) => (this.zoneContext.templates[template.name] = template) 96 | ); 97 | } 98 | 99 | get isArray(): boolean { 100 | return Array.isArray(this.zoneContext.data); 101 | } 102 | 103 | /** 104 | * Propagate the element's click event 105 | * 106 | * If the index is undefined, then data is the data object, otherwise data is the 107 | * data object's index 108 | * @param {Event} event - The event that triggered the click. 109 | * @param {any} [data] - The data object that was clicked. 110 | * @param {number} [index] - The index of the item in the list. 111 | */ 112 | onItemClicked(event: Event, data?: any, index?: number) { 113 | data = typeof index === 'undefined'? data : data[index]; 114 | this.itemClicked.next({data, index, event}); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /projects/lib/src/public-api.ts: -------------------------------------------------------------------------------- 1 | // Necessary to resolve the '$localize' marker 2 | import '@angular/localize/init'; 3 | 4 | /* 5 | * Public API Surface of lib 6 | */ 7 | 8 | export * from './configurable'; 9 | export * from './configuration'; 10 | export * from './configurator'; 11 | export * from './conditions'; 12 | export * from './dynamic-views'; 13 | export * from './utils'; 14 | 15 | export * from './svg/svg-icons'; 16 | export * from "./utils/svg-icon"; 17 | 18 | export * from "./uib.module"; -------------------------------------------------------------------------------- /projects/lib/src/uib.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | 3 | import { AutocompleteComponent, CheckboxComponent, ColorPickerComponent, ConditionPipe, ConfigModule, ConfiguratorComponent, ImageSelectorComponent, ItemComponent, MultiSelectComponent, NgModelChangeDebouncedDirective, PaletteComponent, TemplateNameDirective, ToastComponent, ToolbarComponent, TooltipDirective, ZoneComponent } from "./public-api"; 4 | 5 | 6 | const components = [ 7 | ConfigModule, 8 | // standalone components 9 | ConditionPipe, 10 | ConfiguratorComponent, 11 | ZoneComponent, 12 | ItemComponent, 13 | ToolbarComponent, 14 | ToastComponent, 15 | AutocompleteComponent, 16 | CheckboxComponent, 17 | ImageSelectorComponent, 18 | ColorPickerComponent, 19 | MultiSelectComponent, 20 | PaletteComponent, 21 | // directives 22 | TemplateNameDirective, 23 | TooltipDirective, 24 | NgModelChangeDebouncedDirective, 25 | ] 26 | 27 | @NgModule({ 28 | imports: [...components], 29 | exports: [...components] 30 | }) 31 | export class uibModule {} -------------------------------------------------------------------------------- /projects/lib/src/utils/autocomplete/autocomplete.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from "@angular/common"; 2 | import { Component, Input, OnInit, Output, EventEmitter, ContentChild, TemplateRef } from "@angular/core"; 3 | import { fromEvent, merge, Observable, of } from "rxjs"; 4 | import { filter, switchMap } from "rxjs/operators"; 5 | 6 | @Component({ 7 | selector: 'uib-autocomplete', 8 | standalone: true, 9 | imports: [CommonModule], 10 | template: ` 11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 | 19 | {{suggest}} 20 | `, 21 | styles: [` 22 | .card { 23 | z-index: 3; 24 | box-shadow: 0 5px 7px rgb(0 0 0 / 8%); 25 | } 26 | .list-group { 27 | top: 0; 28 | width: 100%; 29 | max-height: 200px; 30 | overflow: auto; 31 | } 32 | .list-group-item-action { 33 | cursor: pointer; 34 | } 35 | `] 36 | }) 37 | export class AutocompleteComponent implements OnInit { 38 | @Input() inputElement: HTMLInputElement; 39 | @Input() suggestGenerator?: (value: string) => Observable; 40 | @Input() allSuggests?: string[]; 41 | @Output() select = new EventEmitter(); 42 | @ContentChild("itemTpl", {static: false}) itemTpl: TemplateRef; 43 | suggests$ = new Observable(); 44 | clickInProgress = false; 45 | 46 | ngOnInit() { 47 | this.suggests$ = merge( 48 | fromEvent(this.inputElement, 'input'), 49 | fromEvent(this.inputElement, 'focus'), 50 | fromEvent(this.inputElement, 'blur').pipe(filter(() => (!this.clickInProgress))), // Filter out blur event caused by a click on the autocomplete 51 | this.select // Hide the autocomplete when an item has been selected 52 | ).pipe( 53 | switchMap(event => { 54 | if(typeof event !== 'string' && event.type !== 'blur') { 55 | if(this.suggestGenerator) { 56 | return this.suggestGenerator(this.inputElement.value); 57 | } 58 | else if(this.allSuggests) { 59 | return of(this.allSuggests.filter(suggest => suggest !== this.inputElement.value && suggest.includes(this.inputElement.value))); 60 | } 61 | } 62 | return of([]); 63 | }) 64 | ); 65 | } 66 | 67 | onSelect(value: string) { 68 | this.inputElement.value = value; 69 | this.select.next(value); 70 | } 71 | 72 | } -------------------------------------------------------------------------------- /projects/lib/src/utils/directive/model-change.directive.ts: -------------------------------------------------------------------------------- 1 | import { AfterContentInit, Directive, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; 2 | import { NgModel } from '@angular/forms'; 3 | import { Subscription } from 'rxjs'; 4 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; 5 | 6 | @Directive({ 7 | selector: '[ngModelChangeDebounced]', 8 | standalone: true 9 | }) 10 | export class NgModelChangeDebouncedDirective implements OnDestroy, AfterContentInit { 11 | @Input() ngModelDebounceTime = 1000; 12 | @Output() ngModelChangeDebounced = new EventEmitter() 13 | 14 | private subs: Subscription; 15 | 16 | constructor(private ngModel: NgModel) {} 17 | 18 | ngAfterContentInit(): void { 19 | this.subs = this.ngModel.update 20 | .pipe( 21 | distinctUntilChanged(), 22 | debounceTime(this.ngModelDebounceTime), 23 | ) 24 | .subscribe((value) => { 25 | this.ngModelChangeDebounced.emit(value); 26 | }); 27 | } 28 | 29 | ngOnDestroy(): void { 30 | this.subs.unsubscribe(); 31 | } 32 | } -------------------------------------------------------------------------------- /projects/lib/src/utils/directive/template-name.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input, TemplateRef } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[uib-template]', 5 | standalone: true 6 | }) 7 | export class TemplateNameDirective { 8 | /** 9 | * template's name 10 | */ 11 | @Input('uib-template') name: string; 12 | 13 | // Properties below are used by the configurator 14 | /** 15 | * name used by the configurator to identify the component 16 | */ 17 | @Input('uib-templateDisplay') display?: string; 18 | /** 19 | * icon used by the configurator to identify the component 20 | */ 21 | @Input('uib-templateIconClass') iconClass?: string; 22 | /** 23 | * description used by the configurator 24 | */ 25 | @Input('uib-templateDescription') description?: string; 26 | 27 | templateRef: TemplateRef; 28 | 29 | constructor(protected readonly _templateRef: TemplateRef) { 30 | this.templateRef = _templateRef; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /projects/lib/src/utils/directive/tooltip.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, Input, OnChanges, OnDestroy } from "@angular/core"; 2 | import { Tooltip } from "bootstrap"; 3 | 4 | export type TooltipPlacement = 'auto' | 'top' | 'bottom' | 'left' | 'right'; 5 | 6 | @Directive({ 7 | selector: '[uib-tooltip]', 8 | standalone: true 9 | }) 10 | export class TooltipDirective implements OnChanges, OnDestroy { 11 | @Input('uib-tooltip') html: string; 12 | 13 | /** 14 | * Allow Bootstrap tooltip's options configuration 15 | * 16 | * * `title`, `placement` and `container` options are removed from Bootstrap options as they use their own inputs. 17 | * * `html`, `sanitize`, `delay` and `trigger` options are sets with different values as well by default but can 18 | * be overriden here. 19 | */ 20 | @Input() config?: Partial> = { 21 | html: true, 22 | sanitize: false, 23 | delay: { show: 300, hide: 0 }, 24 | trigger: 'hover' 25 | }; 26 | 27 | /** 28 | * How to position the tooltip. 29 | */ 30 | @Input() placement: TooltipPlacement = "auto"; 31 | 32 | /** 33 | * Append the tooltip to a specific element. 34 | * 35 | * By default, tooltip is append to `uib-bootstrap` component. 36 | * When `undefined`, tooltip is append to his host element. 37 | */ 38 | @Input() container?: string | Element = ".uib-bootstrap"; 39 | 40 | tooltip?: Tooltip; 41 | 42 | constructor(public el: ElementRef){} 43 | 44 | ngOnChanges() { 45 | this.tooltip?.dispose(); // Force the creation of a new tooltip, or else the content is not updated 46 | if(this.html) { 47 | this.tooltip = Tooltip.getOrCreateInstance(this.el.nativeElement, { 48 | ...this.config, 49 | placement: this.placement, 50 | title: this.html, 51 | container: this.container || this.el.nativeElement 52 | }); 53 | } 54 | } 55 | 56 | ngOnDestroy() { 57 | this.tooltip?.dispose(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /projects/lib/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; 2 | -------------------------------------------------------------------------------- /projects/lib/src/utils/modal/modal.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, ViewChild } from "@angular/core"; 2 | import { Modal } from "bootstrap"; 3 | 4 | @Component({ 5 | selector: 'uib-modal', 6 | standalone: true, 7 | template: ` 8 | 25 | `, 26 | changeDetection: ChangeDetectionStrategy.OnPush 27 | }) 28 | export class ModalComponent implements AfterViewInit, OnChanges, OnDestroy { 29 | 30 | @ViewChild('modal', {static: true}) el: ElementRef; 31 | @Input() title: string; 32 | @Input() show: boolean; 33 | @Output() close = new EventEmitter(); 34 | 35 | modal?: Modal; 36 | 37 | ngOnChanges() { 38 | if(this.show && !this.modal) { 39 | this.modal = Modal.getOrCreateInstance(this.el.nativeElement, {backdrop: false}); // Backdrop causes issues when the modal is embedded in a fixed container 40 | this.modal.show(); 41 | } 42 | } 43 | 44 | ngOnDestroy() { 45 | this.modal?.dispose(); 46 | } 47 | 48 | ngAfterViewInit() { 49 | this.el.nativeElement.addEventListener('hidden.bs.modal', () => this.onModalClose(false)); 50 | } 51 | 52 | onModalClose(success: boolean) { 53 | this.close.next(success); 54 | this.modal?.hide(); 55 | this.modal = undefined; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /projects/lib/src/utils/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './autocomplete/autocomplete.component'; 2 | 3 | export * from './directive/template-name.directive'; 4 | export * from './directive/model-change.directive'; 5 | export * from './directive/tooltip.directive'; 6 | 7 | export * from './modal/modal.component'; 8 | 9 | export * from './toast/toast.component' 10 | export * from './toast/toast.service'; 11 | 12 | export * from "./typings"; 13 | 14 | export * from '../dynamic-views/zone/zone-context.service'; 15 | 16 | export * from "./svg-icon/registry"; 17 | export * from "./svg-icon/svg-icon.component"; 18 | export * from "./svg-icon/types"; -------------------------------------------------------------------------------- /projects/lib/src/utils/svg-icon/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; -------------------------------------------------------------------------------- /projects/lib/src/utils/svg-icon/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './svg-icon.module'; -------------------------------------------------------------------------------- /projects/lib/src/utils/svg-icon/registry.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { Inject, Injectable, Injector } from '@angular/core'; 3 | 4 | import { SVG_CONFIG, SVG_ICONS_CONFIG, SvgIconType } from './types'; 5 | 6 | class SvgIcon { 7 | init = false; 8 | 9 | constructor(public content: string) {} 10 | } 11 | 12 | @Injectable({ providedIn: 'root' }) 13 | export class SvgIconRegistry { 14 | private readonly svgMap = new Map(); 15 | private readonly document: Document; 16 | 17 | constructor(injector: Injector, @Inject(SVG_ICONS_CONFIG) config: SVG_CONFIG) { 18 | this.document = injector.get(DOCUMENT); 19 | 20 | if (config.icons) { 21 | this.register(config.icons); 22 | } 23 | 24 | if (config.missingIconFallback) { 25 | this.register(config.missingIconFallback); 26 | } 27 | } 28 | 29 | getAll() { 30 | return this.svgMap; 31 | } 32 | 33 | get(key: string | undefined): string | undefined { 34 | const icon = key && this.svgMap.get(key); 35 | 36 | if (!icon) { 37 | return undefined; 38 | } 39 | 40 | if (!icon.init) { 41 | const svg = this.toElement(icon.content); 42 | svg.setAttribute('fit', ''); 43 | svg.setAttribute('height', '100%'); 44 | svg.setAttribute('width', '100%'); 45 | svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); 46 | svg.setAttribute('focusable', 'false'); 47 | 48 | icon.content = svg.outerHTML; 49 | icon.init = true; 50 | } 51 | 52 | return icon.content; 53 | } 54 | 55 | register(icons: SvgIconType | SvgIconType[]) { 56 | for (const { name, data } of Array.isArray(icons) ? icons : [icons]) { 57 | if (!this.svgMap.has(name)) { 58 | this.svgMap.set(name, new SvgIcon(data)); 59 | } 60 | } 61 | } 62 | 63 | getSvgElement(name: string): SVGSVGElement | undefined { 64 | const content = this.get(name); 65 | 66 | if (!content) { 67 | return undefined; 68 | } 69 | 70 | const div = this.document.createElement('div'); 71 | div.innerHTML = content; 72 | 73 | return div.querySelector('svg') as SVGSVGElement; 74 | } 75 | 76 | private toElement(content: string): SVGElement { 77 | const div = this.document.createElement('div'); 78 | div.innerHTML = content; 79 | 80 | return div.querySelector('svg') as SVGElement; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /projects/lib/src/utils/svg-icon/svg-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef, Inject, Input } from '@angular/core'; 2 | 3 | import { SvgIconRegistry } from './registry'; 4 | import { SVG_CONFIG, SVG_ICONS_CONFIG } from './types'; 5 | 6 | @Component({ 7 | selector: 'svg-icon', 8 | standalone: true, 9 | template: '', 10 | host: { 11 | role: 'img', 12 | 'aria-hidden': 'true', 13 | }, 14 | styles: [ 15 | ` 16 | :host { 17 | display: inline-block; 18 | fill: currentColor; 19 | font-size: 1rem; 20 | 21 | transform: rotate(var(--rotate, 0deg)); 22 | transition: transform 0.2s ease-in-out; 23 | } 24 | `, 25 | ], 26 | changeDetection: ChangeDetectionStrategy.OnPush, 27 | }) 28 | export class SvgIconComponent { 29 | @Input() 30 | set key(name: string) { 31 | const icon = this.registry.get(name) ?? this.registry.get(this.config.missingIconFallback?.name); 32 | 33 | if (icon) { 34 | this.element.setAttribute('aria-label', `${name}-icon`); 35 | this.element.classList.remove(`svg-icon-${this.lastKey}`); 36 | this.lastKey = name; 37 | this.element.classList.add(`svg-icon-${name}`); 38 | this.element.innerHTML = icon; 39 | } 40 | } 41 | 42 | @Input() 43 | set size(value: keyof SVG_CONFIG['sizes']) { 44 | this.element.style.fontSize = this.mergedConfig.sizes[value]; 45 | } 46 | 47 | @Input() set width(value: number | string) { 48 | this.element.style.width = coerceCssPixelValue(value); 49 | } 50 | 51 | @Input() set height(value: number | string) { 52 | this.element.style.height = coerceCssPixelValue(value); 53 | } 54 | 55 | @Input() 56 | set fontSize(value: number | string) { 57 | this.element.style.fontSize = coerceCssPixelValue(value); 58 | } 59 | 60 | @Input() 61 | set color(color: string) { 62 | this.element.style.color = color; 63 | } 64 | 65 | private mergedConfig: SVG_CONFIG; 66 | private lastKey!: string; 67 | 68 | constructor( 69 | private host: ElementRef, 70 | private registry: SvgIconRegistry, 71 | @Inject(SVG_ICONS_CONFIG) private config: SVG_CONFIG 72 | ) { 73 | this.mergedConfig = this.createConfig(); 74 | this.element.style.fontSize = this.mergedConfig.sizes[this.mergedConfig.defaultSize || 'md']; 75 | } 76 | 77 | get element() { 78 | return this.host.nativeElement; 79 | } 80 | 81 | private createConfig() { 82 | const defaults: SVG_CONFIG = { 83 | sizes: { 84 | xs: '0.5rem', 85 | sm: '0.75rem', 86 | md: '1rem', 87 | lg: '1.5rem', 88 | xl: '2rem', 89 | xxl: '2.5rem', 90 | }, 91 | }; 92 | 93 | return { 94 | ...defaults, 95 | ...this.config, 96 | }; 97 | } 98 | } 99 | 100 | function coerceCssPixelValue(value: any): string { 101 | if (value == null) { 102 | return ''; 103 | } 104 | 105 | return typeof value === 'string' ? value : `${value}px`; 106 | } 107 | -------------------------------------------------------------------------------- /projects/lib/src/utils/svg-icon/svg-icon.module.ts: -------------------------------------------------------------------------------- 1 | import { Inject, ModuleWithProviders, NgModule, Optional, Self } from '@angular/core'; 2 | 3 | import { SvgIconRegistry } from './registry'; 4 | import { SVG_CONFIG, SVG_ICONS, SVG_ICONS_CONFIG, SVG_MISSING_ICON_FALLBACK, SvgIconType } from './types'; 5 | 6 | @NgModule() 7 | export class SvgIconsModule { 8 | static forRoot(config: Partial = {}): ModuleWithProviders { 9 | return { 10 | ngModule: SvgIconsModule, 11 | providers: [ 12 | { 13 | provide: SVG_ICONS_CONFIG, 14 | useValue: config, 15 | }, 16 | ], 17 | }; 18 | } 19 | 20 | static forChild(icons: SvgIconType | SvgIconType[]): ModuleWithProviders { 21 | return { 22 | ngModule: SvgIconsModule, 23 | providers: [{ provide: SVG_ICONS, useValue: icons, multi: true }], 24 | }; 25 | } 26 | 27 | constructor( 28 | @Optional() @Self() @Inject(SVG_ICONS) icons: SvgIconType[] | SvgIconType[][], 29 | @Optional() @Inject(SVG_MISSING_ICON_FALLBACK) missingIconFallback: SvgIconType, 30 | private service: SvgIconRegistry 31 | ) { 32 | if (icons) { 33 | this.service.register(([] as SvgIconType[]).concat(...icons)); 34 | } 35 | 36 | if (missingIconFallback) { 37 | this.service.register(missingIconFallback); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /projects/lib/src/utils/svg-icon/types.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | 3 | export interface SvgIconType { 4 | name: string; 5 | data: string; 6 | } 7 | 8 | export interface SVG_CONFIG { 9 | icons?: SvgIconType | SvgIconType[]; 10 | color?: string; 11 | defaultSize?: keyof SVG_CONFIG['sizes']; 12 | missingIconFallback?: SvgIconType; 13 | sizes: { 14 | xs?: string; 15 | sm?: string; 16 | md?: string; 17 | lg?: string; 18 | xl?: string; 19 | xxl?: string; 20 | }; 21 | } 22 | 23 | export const SVG_ICONS_CONFIG = new InjectionToken('SVG_ICONS_CONFIG'); 24 | export const SVG_ICONS = new InjectionToken('SVG_ICONS'); 25 | export const SVG_MISSING_ICON_FALLBACK = new InjectionToken('SVG_MISSING_ICON_FALLBACK'); 26 | -------------------------------------------------------------------------------- /projects/lib/src/utils/toast/toast.component.html: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /projects/lib/src/utils/toast/toast.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core'; 2 | import { Toast } from 'bootstrap'; 3 | import { Subscription } from 'rxjs'; 4 | import { ToastAction, ToastMessage, ToastService } from './toast.service'; 5 | import { CommonModule } from '@angular/common'; 6 | 7 | @Component({ 8 | selector: 'uib-toast', 9 | standalone: true, 10 | imports: [CommonModule], 11 | templateUrl: './toast.component.html', 12 | styles: [` 13 | .alert { 14 | width: 350px; 15 | max-width: 100%; 16 | box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); 17 | z-index: 1046; 18 | } 19 | `] 20 | }) 21 | export class ToastComponent implements OnDestroy, AfterViewInit { 22 | @ViewChild('toastRef', {static: true}) el: ElementRef 23 | 24 | @Input() options: Toast.Options = {animation: true, autohide: true, delay: 5000}; 25 | 26 | toast: Toast; 27 | message: ToastMessage|null; 28 | 29 | private sub: Subscription; 30 | 31 | constructor(private toastService: ToastService) { 32 | } 33 | 34 | ngAfterViewInit(): void { 35 | this.sub = this.toastService.onToastMessage.subscribe((message: ToastMessage|null) => { 36 | this.message = message; 37 | if (this.el) { 38 | if(message) { 39 | this.toast = Toast.getOrCreateInstance(this.el.nativeElement, message.options || this.options); 40 | this.toast.show(); 41 | } 42 | else { 43 | this.toast.hide(); 44 | } 45 | } else { 46 | console.warn('Toast not found!'); 47 | } 48 | }); 49 | } 50 | 51 | ngOnDestroy(): void { 52 | this.sub.unsubscribe(); 53 | } 54 | 55 | onAction(action: ToastAction) { 56 | action.action(); 57 | if(action.hideToast) { 58 | this.toast.hide(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /projects/lib/src/utils/toast/toast.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Toast } from 'bootstrap'; 3 | import { Subject } from 'rxjs'; 4 | 5 | export type BsStyle = 'info' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'light' | 'dark'; 6 | 7 | export interface ToastAction { 8 | text?: string; 9 | style?: string | string; 10 | icon?: string; 11 | hideToast?: boolean; 12 | action: () => void; 13 | } 14 | 15 | export interface ToastMessage { 16 | icon?: string; 17 | message: string; 18 | style?: BsStyle | string; 19 | actions?: ToastAction[]; 20 | options?: Toast.Options; 21 | } 22 | 23 | @Injectable({providedIn: 'root'}) 24 | export class ToastService { 25 | 26 | onToastMessage = new Subject(); 27 | 28 | show( 29 | message: string, 30 | style: BsStyle | string = 'info', 31 | actions?: ToastAction[], 32 | options?: Toast.Options 33 | ) { 34 | this.onToastMessage.next({message, style, actions, options}); 35 | } 36 | 37 | hide() { 38 | this.onToastMessage.next(null); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /projects/lib/src/utils/typings.ts: -------------------------------------------------------------------------------- 1 | export type Mutable = { 2 | -readonly [k in keyof T]: T[k]; 3 | } -------------------------------------------------------------------------------- /projects/lib/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin white-stripes { 2 | --stripes-color: hsla(0, 0%, 100%, 0.4); 3 | background-color: #f2f2f2; 4 | background-image: linear-gradient( 5 | 45deg, 6 | var(--stripes-color) 12.5%, 7 | transparent 12.5%, 8 | transparent 50%, 9 | var(--stripes-color) 50%, 10 | var(--stripes-color) 62.5%, 11 | transparent 62.5%, 12 | transparent 100% 13 | ); 14 | background-size: 5.66px 5.66px; 15 | } 16 | 17 | /* when an element have 'edited' state */ 18 | @mixin edited { 19 | /* display current edited element */ 20 | &.edited { 21 | @include white-stripes; 22 | 23 | background-color: lightblue; 24 | outline: 1px dashed lightskyblue; 25 | 26 | /* inset shadow acts as a border */ 27 | box-shadow: inset 0px 0px 0px 2px #fff; 28 | } 29 | } -------------------------------------------------------------------------------- /projects/lib/styles/_selected.scss: -------------------------------------------------------------------------------- 1 | /* when zone is selected apply this style */ 2 | .uib-configurable[selected] { 3 | padding: var(--padding); 4 | 5 | .uib-configurable { 6 | margin: var(--margin); 7 | border-radius: 2px; 8 | } 9 | 10 | .uib-container { 11 | padding: var(--padding); 12 | } 13 | } -------------------------------------------------------------------------------- /projects/lib/styles/_uib-bootstrap.scss: -------------------------------------------------------------------------------- 1 | .uib-bootstrap { 2 | @import "bootstrap/scss/mixins/banner"; 3 | @include bsBanner(""); 4 | 5 | // scss-docs-start import-stack 6 | // Configuration 7 | @import "bootstrap/scss/functions"; 8 | @import "bootstrap/scss/variables"; 9 | @import "bootstrap/scss/maps"; 10 | @import "bootstrap/scss/mixins"; 11 | @import "bootstrap/scss/utilities"; 12 | // Note: Custom variable values only support SassScript inside `#{}`. 13 | 14 | // Colors 15 | // 16 | // Generate palettes for full colors, grays, and theme colors. 17 | 18 | @each $color, $value in $colors { 19 | --#{$prefix}#{$color}: #{$value}; 20 | } 21 | 22 | @each $color, $value in $grays { 23 | --#{$prefix}gray-#{$color}: #{$value}; 24 | } 25 | 26 | @each $color, $value in $theme-colors { 27 | --#{$prefix}#{$color}: #{$value}; 28 | } 29 | 30 | @each $color, $value in $theme-colors-rgb { 31 | --#{$prefix}#{$color}-rgb: #{$value}; 32 | } 33 | 34 | --#{$prefix}white-rgb: #{to-rgb($white)}; 35 | --#{$prefix}black-rgb: #{to-rgb($black)}; 36 | --#{$prefix}body-color-rgb: #{to-rgb($body-color)}; 37 | --#{$prefix}body-bg-rgb: #{to-rgb($body-bg)}; 38 | 39 | // Fonts 40 | 41 | // Note: Use `inspect` for lists so that quoted items keep the quotes. 42 | // See https://github.com/sass/sass/issues/2383#issuecomment-336349172 43 | --#{$prefix}font-sans-serif: #{inspect($font-family-sans-serif)}; 44 | --#{$prefix}font-monospace: #{inspect($font-family-monospace)}; 45 | --#{$prefix}gradient: #{$gradient}; 46 | 47 | // Root and body 48 | // stylelint-disable custom-property-empty-line-before 49 | // scss-docs-start root-body-variables 50 | @if $font-size-root != null { 51 | --#{$prefix}root-font-size: #{$font-size-root}; 52 | } 53 | --#{$prefix}body-font-family: #{$font-family-base}; 54 | @include rfs($font-size-base, --#{$prefix}body-font-size); 55 | --#{$prefix}body-font-weight: #{$font-weight-base}; 56 | --#{$prefix}body-line-height: #{$line-height-base}; 57 | --#{$prefix}body-color: #{$body-color}; 58 | @if $body-text-align != null { 59 | --#{$prefix}body-text-align: #{$body-text-align}; 60 | } 61 | --#{$prefix}body-bg: #{$body-bg}; 62 | // scss-docs-end root-body-variables 63 | 64 | // scss-docs-start root-border-var 65 | --#{$prefix}border-width: #{$border-width}; 66 | --#{$prefix}border-style: #{$border-style}; 67 | --#{$prefix}border-color: #{$border-color}; 68 | --#{$prefix}border-color-translucent: #{$border-color-translucent}; 69 | 70 | --#{$prefix}border-radius: #{$border-radius}; 71 | --#{$prefix}border-radius-sm: #{$border-radius-sm}; 72 | --#{$prefix}border-radius-lg: #{$border-radius-lg}; 73 | --#{$prefix}border-radius-xl: #{$border-radius-xl}; 74 | --#{$prefix}border-radius-2xl: #{$border-radius-2xl}; 75 | --#{$prefix}border-radius-pill: #{$border-radius-pill}; 76 | // scss-docs-end root-border-var 77 | 78 | --#{$prefix}link-color: #{$link-color}; 79 | --#{$prefix}link-hover-color: #{$link-hover-color}; 80 | 81 | --#{$prefix}code-color: #{$code-color}; 82 | 83 | --#{$prefix}highlight-bg: #{$mark-bg}; 84 | 85 | // Layout & components 86 | @import "bootstrap/scss/reboot"; 87 | @import "bootstrap/scss/type"; 88 | @import "bootstrap/scss/images"; 89 | @import "bootstrap/scss/containers"; 90 | @import "bootstrap/scss/grid"; 91 | @import "bootstrap/scss/tables"; 92 | @import "bootstrap/scss/forms"; 93 | @import "bootstrap/scss/buttons"; 94 | @import "bootstrap/scss/transitions"; 95 | @import "bootstrap/scss/dropdown"; 96 | @import "bootstrap/scss/button-group"; 97 | @import "bootstrap/scss/nav"; 98 | @import "bootstrap/scss/navbar"; 99 | @import "bootstrap/scss/card"; 100 | @import "bootstrap/scss/accordion"; 101 | @import "bootstrap/scss/breadcrumb"; 102 | @import "bootstrap/scss/pagination"; 103 | @import "bootstrap/scss/badge"; 104 | @import "bootstrap/scss/alert"; 105 | @import "bootstrap/scss/progress"; 106 | @import "bootstrap/scss/list-group"; 107 | @import "bootstrap/scss/close"; 108 | @import "bootstrap/scss/toasts"; 109 | @import "bootstrap/scss/modal"; 110 | @import "bootstrap/scss/tooltip"; 111 | @import "bootstrap/scss/popover"; 112 | @import "bootstrap/scss/carousel"; 113 | @import "bootstrap/scss/spinners"; 114 | @import "bootstrap/scss/offcanvas"; 115 | @import "bootstrap/scss/placeholders"; 116 | 117 | // Helpers 118 | @import "bootstrap/scss/helpers"; 119 | 120 | // Utilities 121 | @import "bootstrap/scss/utilities/api"; 122 | 123 | 124 | 125 | // Move reboot body rules in .uib-bootstrap: 126 | 127 | // Body 128 | // 129 | // 1. Remove the margin in all browsers. 130 | // 2. As a best practice, apply a default `background-color`. 131 | // 3. Prevent adjustments of font size after orientation changes in iOS. 132 | // 4. Change the default tap highlight to be completely transparent in iOS. 133 | 134 | // scss-docs-start reboot-body-rules 135 | margin: 0; // 1 136 | font-family: var(--#{$prefix}body-font-family); 137 | @include font-size(var(--#{$prefix}body-font-size)); 138 | font-weight: var(--#{$prefix}body-font-weight); 139 | line-height: var(--#{$prefix}body-line-height); 140 | color: var(--#{$prefix}body-color); 141 | text-align: var(--#{$prefix}body-text-align); 142 | background-color: var(--#{$prefix}body-bg); // 2 143 | -webkit-text-size-adjust: 100%; // 3 144 | -webkit-tap-highlight-color: rgba($black, 0); // 4 145 | // scss-docs-end reboot-body-rules 146 | } 147 | -------------------------------------------------------------------------------- /projects/lib/styles/_zone.scss: -------------------------------------------------------------------------------- 1 | @import "_mixins"; 2 | 3 | /* default zone styles */ 4 | .uib-zone { 5 | position: relative; 6 | 7 | /* box-model */ 8 | /* visual */ 9 | border-radius: 4px; 10 | 11 | &:not([selected]) { 12 | outline: 1px dashed lightsteelblue; 13 | } 14 | 15 | /* elements text are not selectable */ 16 | &, 17 | * { 18 | user-select: none; 19 | } 20 | 21 | /* elements within zone are clickable */ 22 | &, 23 | .cursor-default { 24 | cursor: pointer; 25 | } 26 | 27 | /* edited styles */ 28 | &.edited, 29 | .edited { 30 | @include edited; 31 | } 32 | 33 | /* highlight styles sync with tree */ 34 | &.highlight, 35 | .uib-dropzone-content.highlight, 36 | .highlight:not(.uib-dropzone-content) { 37 | outline: 2px solid red; 38 | z-index: 99; 39 | } 40 | 41 | .uib-container { 42 | display: flex; 43 | min-width: 32px; 44 | min-height: 32px; 45 | } 46 | 47 | 48 | /* when hovering a zone or a zone's element */ 49 | &:hover { 50 | outline: 1px dashed lightsteelblue; 51 | // margin: var(--margin); 52 | 53 | [uib-configurable] { 54 | /* box-model */ 55 | 56 | /* visual */ 57 | border-radius: 2px; 58 | 59 | &:not(.uib-container, .highlight, .dndDragging, .uib-dropzone-content) { 60 | outline: 1px dashed yellowgreen; 61 | outline-offset: -3px; 62 | } 63 | 64 | /* Deactivate clicks within templates in edit mode */ 65 | & > *:not(.uib-dropzone) { 66 | pointer-events: none; 67 | } 68 | } 69 | 70 | .uib-dropzone-content { 71 | outline: 1px dashed steelblue; 72 | } 73 | 74 | /* highlight styles */ 75 | &.highlight, 76 | .uib-dropzone-content.highlight { 77 | outline: 2px solid red; 78 | z-index: 99; 79 | } 80 | } 81 | } 82 | 83 | /* while dragging remove outline and add a slight border */ 84 | [uib-configurable].dndDragging { 85 | background-color: lightgray; 86 | outline: none !important; 87 | border: 1px solid gray !important; 88 | } -------------------------------------------------------------------------------- /projects/lib/styles/ui-builder.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --padding: 0; 3 | --margin: 0; 4 | --menu-top: -18px; 5 | --menu-left: 0px; 6 | } 7 | 8 | /* bootstrap encapsulated inside "uib-bootstrap" namespace */ 9 | @import "_uib-bootstrap"; 10 | 11 | uib-toolbar { 12 | position: fixed; 13 | bottom: 50px; 14 | right: 50px; 15 | z-index: 1046; // just above the configurator 16 | } 17 | 18 | /* zone's styles */ 19 | @import "_zone"; 20 | 21 | .uib-dropzone { 22 | display: flex; 23 | flex-grow: 1; 24 | } 25 | 26 | [uib-configurable] { 27 | min-height: 8px; 28 | min-width: 8px; 29 | } 30 | 31 | .dragPlaceholder { 32 | @include white-stripes; 33 | outline: 2px dashed orange; 34 | 35 | flex-grow: 1; 36 | 37 | min-height: 16px; 38 | min-width: 16px; 39 | 40 | z-index: 99; 41 | } 42 | -------------------------------------------------------------------------------- /projects/lib/tsconfig.doc.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "exclude": ["src/test.ts", "src/**/*.spec.ts"] 4 | } -------------------------------------------------------------------------------- /projects/lib/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": [ 11 | "dom", 12 | "es2022" 13 | ] 14 | }, 15 | "exclude": [ 16 | "src/test.ts", 17 | "**/*.spec.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /projects/lib/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/lib/tsconfig.schematics.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": [ 5 | "es2022", 6 | "dom" 7 | ], 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "noEmitOnError": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": false, 15 | "noImplicitThis": true, 16 | "noUnusedParameters": true, 17 | "noUnusedLocals": true, 18 | "rootDir": "./schematics", 19 | "outDir": "../../dist/lib/schematics", 20 | "skipDefaultLibCheck": true, 21 | "skipLibCheck": true, 22 | "sourceMap": true, 23 | "strictNullChecks": true, 24 | "target": "ES2022", 25 | "types": [ 26 | "node" 27 | ] 28 | }, 29 | "include": [ 30 | "schematics/**/*" 31 | ], 32 | "exclude": [ 33 | "schematics/*/files/**/*" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /projects/lib/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": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "noImplicitReturns": true, 9 | "esModuleInterop": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "paths": { 13 | "@sinequa/ngx-ui-builder": [ 14 | "dist/lib" 15 | ] 16 | }, 17 | "declaration": false, 18 | "experimentalDecorators": true, 19 | "moduleResolution": "node", 20 | "importHelpers": true, 21 | "preserveSymlinks": true, 22 | "noImplicitThis": true, 23 | "noUnusedLocals": true, 24 | "strictFunctionTypes": true, 25 | "strictNullChecks": true, 26 | "target": "ES2022", 27 | "module": "ESNext", 28 | "lib": [ 29 | "ES2022", 30 | "dom" 31 | ] 32 | }, 33 | "angularCompilerOptions": { 34 | "fullTemplateTypeCheck": true, 35 | "strictInjectionParameters": true 36 | } 37 | } 38 | --------------------------------------------------------------------------------