├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .stylelintignore ├── .stylelintrc.js ├── .template-lintrc.js ├── .watchmanconfig ├── LICENSE ├── README.md ├── app ├── app.js ├── components │ └── .gitkeep ├── controllers │ ├── .gitkeep │ └── index.js ├── helpers │ └── .gitkeep ├── index.html ├── models │ └── .gitkeep ├── router.js ├── routes │ ├── .gitkeep │ ├── application.js │ └── index.js ├── services │ └── solid-auth.js ├── styles │ └── app.css └── templates │ ├── application.hbs │ └── index.hbs ├── config ├── ember-cli-update.json ├── environment.js ├── optional-features.json └── targets.js ├── ember-cli-build.js ├── package-lock.json ├── package.json ├── public ├── forkme_right_gray.png └── robots.txt ├── testem.js └── tests ├── helpers └── index.js ├── index.html ├── integration └── .gitkeep ├── test-helper.js └── unit ├── .gitkeep ├── controllers └── index-test.js └── routes └── index-test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript 4 | rather than JavaScript by default, when a TypeScript version of a given blueprint is available. 5 | */ 6 | "isTypeScriptProject": false 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # misc 8 | /coverage/ 9 | !.* 10 | .*/ 11 | 12 | # ember-try 13 | /.node_modules.ember-try/ 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | parser: '@babel/eslint-parser', 6 | parserOptions: { 7 | ecmaVersion: 'latest', 8 | sourceType: 'module', 9 | requireConfigFile: false, 10 | babelOptions: { 11 | plugins: [ 12 | ['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }], 13 | ], 14 | }, 15 | }, 16 | plugins: ['ember'], 17 | extends: [ 18 | 'eslint:recommended', 19 | 'plugin:ember/recommended', 20 | 'plugin:prettier/recommended', 21 | ], 22 | env: { 23 | browser: true, 24 | }, 25 | rules: {}, 26 | overrides: [ 27 | // node files 28 | { 29 | files: [ 30 | './.eslintrc.js', 31 | './.prettierrc.js', 32 | './.stylelintrc.js', 33 | './.template-lintrc.js', 34 | './ember-cli-build.js', 35 | './testem.js', 36 | './blueprints/*/index.js', 37 | './config/**/*.js', 38 | './lib/*/index.js', 39 | './server/**/*.js', 40 | ], 41 | parserOptions: { 42 | sourceType: 'script', 43 | }, 44 | env: { 45 | browser: false, 46 | node: true, 47 | }, 48 | extends: ['plugin:n/recommended'], 49 | }, 50 | { 51 | // test files 52 | files: ['tests/**/*-test.{js,ts}'], 53 | extends: ['plugin:qunit/recommended'], 54 | }, 55 | ], 56 | }; 57 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | concurrency: 11 | group: ci-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | name: "Lint" 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Install Node 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 18 26 | cache: npm 27 | - name: Install Dependencies 28 | run: npm i 29 | - name: Lint 30 | run: npm run lint 31 | 32 | test: 33 | name: "Test" 34 | runs-on: ubuntu-latest 35 | timeout-minutes: 10 36 | 37 | steps: 38 | - uses: actions/checkout@v3 39 | - name: Install Node 40 | uses: actions/setup-node@v3 41 | with: 42 | node-version: 18 43 | cache: npm 44 | - name: Install Dependencies 45 | run: npm i 46 | - name: Run Tests 47 | run: npm test 48 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | pull_request: {} 8 | permissions: 9 | contents: write 10 | jobs: 11 | build-and-deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 🛎️ 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup Node ✨ 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 16.x 21 | cache: npm 22 | 23 | - name: Install and Build 🔧 24 | run: | 25 | npm i 26 | npm run build 27 | - name: Deploy 🚀 28 | uses: JamesIves/github-pages-deploy-action@releases/v4 29 | with: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | BRANCH: pages 32 | FOLDER: dist 33 | CLEAN: true 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /declarations/ 4 | 5 | # dependencies 6 | /node_modules/ 7 | 8 | # misc 9 | /.env* 10 | /.pnp* 11 | /.eslintcache 12 | /coverage/ 13 | /npm-debug.log* 14 | /testem.log 15 | /yarn-error.log 16 | .idea 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /npm-shrinkwrap.json.ember-try 21 | /package.json.ember-try 22 | /package-lock.json.ember-try 23 | /yarn.lock.ember-try 24 | 25 | # broccoli-debug 26 | /DEBUG/ 27 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # misc 8 | /coverage/ 9 | !.* 10 | .*/ 11 | 12 | # ember-try 13 | /.node_modules.ember-try/ 14 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | overrides: [ 5 | { 6 | files: '*.{js,ts}', 7 | options: { 8 | singleQuote: true, 9 | }, 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | # unconventional files 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # addons 8 | /.node_modules.ember-try/ 9 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: ['stylelint-config-standard', 'stylelint-prettier/recommended'], 5 | }; 6 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["dist"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Ieben Smessaert 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FormGenerator 2 | 3 | A form generator app with Solid - Google Forms but the Solid way. 4 | 5 | [![DOI](https://zenodo.org/badge/527624255.svg)](https://zenodo.org/doi/10.5281/zenodo.10285191) 6 | 7 | This application functions as a proof of concept for the Solid ecosystem. It is a form generator that allows users to 8 | create form definitions and share them with other users. The generated form definition as RDF is stored in a user's Pod 9 | and can then be used together with a form renderer to render the form. 10 | 11 | Such a form renderer is not part of this repository, but can be found 12 | at [SolidLabResearch/FormViewer](https://github.com/SolidLabResearch/FormViewer), [SolidLabResearch/FormRenderer](https://github.com/SolidLabResearch/FormRenderer), or [SolidLabResearch/FormCli](https://github.com/SolidLabResearch/FormCli). 13 | 14 | This application functions as the solution for 15 | the [[SolidLabResearch/Challenges#64] Drag & drop form builder app to build a basic RDF form definition](https://github.com/SolidLabResearch/Challenges/issues/64) 16 | challenge which is part of 17 | the [[SolidLabResearch/Challenges#19] Solid basic form builder (Google Forms but the Solid way)](https://github.com/SolidLabResearch/Challenges/issues/19) 18 | scenario. 19 | 20 | A live version of this application can be found at [http://solidlabresearch.github.io/FormGenerator/](http://solidlabresearch.github.io/FormGenerator/). 21 | 22 | ## Prerequisites 23 | 24 | You will need the following things properly installed on your computer. 25 | 26 | * [Git](https://git-scm.com/) 27 | * [Node.js](https://nodejs.org/) (with npm) 28 | * [Ember CLI](https://cli.emberjs.com/release/) 29 | * [Google Chrome](https://google.com/chrome/) 30 | 31 | ## Installation 32 | 33 | * `git clone ` this repository 34 | * `cd FormGenerator` 35 | * `npm install` 36 | 37 | ## Running / Development 38 | 39 | * `ember serve` (or `npx ember serve` if ember not installed globally) 40 | * Visit your app at [http://localhost:4200](http://localhost:4200). 41 | * Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests). 42 | 43 | ### Code Generators 44 | 45 | Make use of the many generators for code, try `ember help generate` for more details 46 | 47 | ### Running Tests 48 | 49 | * `ember test` 50 | * `ember test --server` 51 | 52 | ### Linting 53 | 54 | * `npm run lint` 55 | * `npm run lint:fix` 56 | 57 | ### Building 58 | 59 | * `ember build` (development) 60 | * `ember build --environment production` (production) 61 | 62 | ### Deploying 63 | 64 | Just upload the content in `dist/` to your webserver after building as described above. 65 | 66 | ## Contribution 67 | 68 | We make use of [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). 69 | 70 | When making changes to a pull request, we prefer to update the existing commits with a rebase instead of appending new 71 | commits. 72 | 73 | ## Further Reading / Useful Links 74 | 75 | * [ember.js](https://emberjs.com/) 76 | * [ember-cli](https://cli.emberjs.com/release/) 77 | * Development Browser Extensions 78 | * [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi) 79 | * [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/) 80 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from 'form-generator/config/environment'; 5 | 6 | export default class App extends Application { 7 | modulePrefix = config.modulePrefix; 8 | podModulePrefix = config.podModulePrefix; 9 | Resolver = Resolver; 10 | } 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | -------------------------------------------------------------------------------- /app/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/app/components/.gitkeep -------------------------------------------------------------------------------- /app/controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/app/controllers/.gitkeep -------------------------------------------------------------------------------- /app/controllers/index.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import { action } from '@ember/object'; 3 | import { tracked } from '@glimmer/tracking'; 4 | import { v4 as uuid } from 'uuid'; 5 | import { 6 | fetch, 7 | getDefaultSession, 8 | handleIncomingRedirect, 9 | login, 10 | logout, 11 | } from '@smessie/solid-client-authn-browser'; 12 | import { service } from '@ember/service'; 13 | import { n3reasoner } from 'eyereasoner'; 14 | 15 | export default class IndexController extends Controller { 16 | queryParams = ['form']; 17 | 18 | @tracked form = null; 19 | 20 | @service solidAuth; 21 | 22 | @tracked authError; 23 | @tracked oidcIssuer = ''; 24 | 25 | /** 26 | * Used in the template. 27 | */ 28 | isEqual = (a, b) => { 29 | return a === b; 30 | }; 31 | 32 | @action 33 | addFormElement(type) { 34 | if (type && !type.startsWith('policy-')) { 35 | // Get base uri from form uri 36 | const baseUri = this.model.loadedFormUri.split('#')[0]; 37 | let field = { 38 | uuid: uuid(), 39 | uri: `${baseUri}#${uuid()}`, 40 | widget: type, 41 | label: '', 42 | property: '', 43 | order: this.model.fields.length, 44 | isSelect: type === 'dropdown', 45 | options: [], 46 | }; 47 | 48 | if ( 49 | this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' 50 | ) { 51 | field = { 52 | ...field, 53 | ...{ 54 | type: type, 55 | required: false, 56 | multiple: false, 57 | canHavePlaceholder: type === 'string' || type === 'textarea', 58 | placeholder: '', 59 | canHaveChoiceBinding: false, 60 | }, 61 | }; 62 | } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { 63 | field = { 64 | ...field, 65 | ...{ 66 | type: this.getSolidUiRdfTypeFromWidgetType(type), 67 | required: false, 68 | multiple: false, 69 | listSubject: `${baseUri}#${uuid()}`, 70 | canHavePlaceholder: false, 71 | canHaveChoiceBinding: true, 72 | }, 73 | ...(type === 'dropdown' 74 | ? { 75 | choice: `${baseUri}#${uuid()}`, 76 | } 77 | : {}), 78 | }; 79 | } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { 80 | field = { 81 | ...field, 82 | ...this.getShaclDatatypeOrNodeKindFromWidgetType(type), 83 | ...{ 84 | minCount: 0, 85 | maxCount: 1, 86 | canHavePlaceholder: false, 87 | canHaveChoiceBinding: false, 88 | }, 89 | }; 90 | } 91 | 92 | this.model.fields = [...this.model.fields, field]; 93 | } 94 | } 95 | 96 | @action 97 | async save(event) { 98 | event.preventDefault(); 99 | 100 | event.target.disabled = true; 101 | event.target.innerText = 'Saving...'; 102 | 103 | if (!(await this.validateInputs())) { 104 | event.target.disabled = false; 105 | event.target.innerText = 'Save'; 106 | return; 107 | } 108 | 109 | // Update order of fields. 110 | this.model.fields.forEach((field, index) => { 111 | field.order = index; 112 | }); 113 | 114 | // Calculate differences between the original form and the current form and save those using N3 Patch. 115 | const fieldsToInsert = []; 116 | const fieldsToDelete = []; 117 | 118 | // Find all new fields. 119 | this.model.fields.forEach((field) => { 120 | // Check if it is not already in the original form by checking the uuid. 121 | if (!this.model.originalFields.find((f) => f.uuid === field.uuid)) { 122 | fieldsToInsert.push(field); 123 | } 124 | }); 125 | 126 | // Find all deleted fields. 127 | this.model.originalFields.forEach((field) => { 128 | // Check if it is not already in the current form by checking the uuid. 129 | if (!this.model.fields.find((f) => f.uuid === field.uuid)) { 130 | fieldsToDelete.push(field); 131 | } 132 | }); 133 | 134 | // Find all the updated fields. 135 | this.model.fields.forEach((field) => { 136 | // Find the original field. 137 | const originalField = this.model.originalFields.find( 138 | (f) => f.uuid === field.uuid, 139 | ); 140 | if (originalField) { 141 | // Check if the field has been updated. 142 | if ( 143 | field.property !== originalField.property || 144 | field.label !== originalField.label || 145 | field.order !== originalField.order || 146 | field.required !== originalField.required || 147 | field.multiple !== originalField.multiple || 148 | field.placeholder !== originalField.placeholder || 149 | field.choice !== originalField.choice || 150 | field.nodeKind !== originalField.nodeKind || 151 | field.minCount !== originalField.minCount || 152 | field.maxCount !== originalField.maxCount || 153 | field.placeholder !== originalField.placeholder || 154 | !this.optionsAreEqual(field.options, originalField.options) 155 | ) { 156 | fieldsToInsert.push(field); 157 | fieldsToDelete.push(originalField); 158 | } 159 | } 160 | }); 161 | 162 | if ( 163 | this.model.formTargetClass === this.model.originalFormTargetClass && 164 | fieldsToInsert.length === 0 && 165 | fieldsToDelete.length === 0 && 166 | this.policiesAreEqual(this.model.policies, this.model.originalPolicies) 167 | ) { 168 | this.model.success = 'No changes detected. No need to save!'; 169 | event.target.disabled = false; 170 | event.target.innerText = 'Save'; 171 | return; 172 | } 173 | 174 | // Remove all N3 rules from the resource. 175 | let matches = await this.model.removeN3RulesFromResource(); 176 | 177 | console.log('old fields', this.model.originalFields); 178 | console.log('new fields', this.model.fields); 179 | 180 | // Form the N3 Patch. 181 | let n3Patch = ` 182 | @prefix solid: . 183 | @prefix ui: . 184 | @prefix sh: . 185 | @prefix form: . 186 | @prefix xsd: . 187 | @prefix skos: . 188 | @prefix rdf: . 189 | @prefix rdfs: . 190 | @prefix owl: . 191 | 192 | _:test a solid:InsertDeletePatch; 193 | solid:inserts { 194 | ${this.stringifyFormSubject( 195 | this.model.loadedFormUri, 196 | this.model.formTargetClass, 197 | this.model.fields, 198 | )} 199 | ${this.stringifyFields( 200 | this.model.loadedFormUri, 201 | this.model.formTargetClass, 202 | fieldsToInsert, 203 | )} 204 | }`; 205 | if (this.model.newForm) { 206 | n3Patch += ` .`; 207 | } else { 208 | n3Patch += ` ; 209 | solid:deletes { 210 | ${this.stringifyFormSubject( 211 | this.model.loadedFormUri, 212 | this.model.originalFormTargetClass, 213 | this.model.originalFields, 214 | )} 215 | ${this.stringifyFields( 216 | this.model.loadedFormUri, 217 | this.model.originalFormTargetClass, 218 | fieldsToDelete, 219 | )} 220 | } . 221 | `; 222 | } 223 | 224 | // Apply the N3 Patch. 225 | const response = await fetch(this.model.loadedFormUri, { 226 | method: 'PATCH', 227 | headers: { 228 | 'Content-Type': 'text/n3', 229 | }, 230 | body: n3Patch, 231 | }); 232 | if (!response.ok) { 233 | this.model.error = `Could not save the form definition!`; 234 | 235 | // We still need to re-add the N3 rules to the resource. 236 | await this.model.addN3RulesToResource(matches); 237 | 238 | event.target.disabled = false; 239 | event.target.innerText = 'Save'; 240 | return; 241 | } 242 | 243 | // Remove all N3 rules that are Submit event policies as we are regenerating them after this. 244 | const keepRules = await Promise.all( 245 | matches.rules.map( 246 | async (rule) => await this.isEventSubmitRule(rule, matches.prefixes), 247 | ), 248 | ); 249 | matches.rules = matches.rules.filter((rule, index) => !keepRules[index]); 250 | 251 | // Add the generated N3 rules to the matches list. 252 | for (const policy of this.stringifyPolicies(this.model.policies)) { 253 | matches.rules.push(policy); 254 | } 255 | 256 | // Make sure the used prefixes are still part of the resource. 257 | matches.prefixes = this.model.addIfNotIncluded( 258 | matches.prefixes, 259 | 'ex', 260 | 'http://example.org/', 261 | ); 262 | matches.prefixes = this.model.addIfNotIncluded( 263 | matches.prefixes, 264 | 'fno', 265 | 'https://w3id.org/function/ontology#', 266 | ); 267 | matches.prefixes = this.model.addIfNotIncluded( 268 | matches.prefixes, 269 | 'pol', 270 | 'https://w3id.org/DFDP/policy#', 271 | ); 272 | matches.prefixes = this.model.addIfNotIncluded( 273 | matches.prefixes, 274 | 'http', 275 | 'http://www.w3.org/2011/http#', 276 | ); 277 | 278 | // Re-add the N3 rules to the resource. 279 | if (await this.model.addN3RulesToResource(matches)) { 280 | this.model.success = 'Successfully saved the form definition!'; 281 | 282 | // On successful save, update the original fields to the current fields. 283 | this.model.originalFields = JSON.parse(JSON.stringify(this.model.fields)); 284 | this.model.originalPolicies = JSON.parse( 285 | JSON.stringify(this.model.policies), 286 | ); 287 | this.model.originalFormTargetClass = this.model.formTargetClass; 288 | } else { 289 | this.model.error = `Could not save the policies as part of the form definition!`; 290 | } 291 | this.model.newForm = false; 292 | event.target.disabled = false; 293 | event.target.innerText = 'Save'; 294 | } 295 | 296 | async validateInputs() { 297 | let valid = true; 298 | 299 | if (!this.model.formTargetClass.trim()) { 300 | this.model.formTargetClassError = 'Please fill in a binding.'; 301 | } 302 | valid &= !this.model.formTargetClassError; 303 | 304 | this.model.fields.forEach((field) => { 305 | if (!field.property?.trim()) { 306 | field.error = 'Please fill in a binding.'; 307 | } 308 | valid &= !field.error; 309 | 310 | if (field.isSelect) { 311 | field.options.forEach((option) => { 312 | if (!option.property?.trim()) { 313 | option.error = 'Please fill in a binding.'; 314 | } 315 | valid &= !option.error; 316 | }); 317 | 318 | if (field.canHaveChoiceBinding) { 319 | valid &= !field.choiceError; 320 | } 321 | } 322 | }); 323 | 324 | this.model.policies.forEach((policy) => { 325 | policy.urlError = ''; 326 | policy.contentTypeError = ''; 327 | 328 | if (!policy.url.trim()) { 329 | policy.urlError = 'Please fill in a URL.'; 330 | } 331 | valid &= !policy.urlError; 332 | 333 | if (policy.executionTarget === 'http://www.w3.org/2011/http#Request') { 334 | if ( 335 | !['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(policy.method) 336 | ) { 337 | policy.methodError = 'Please choose a valid HTTP method.'; 338 | } 339 | valid &= !policy.methodError; 340 | 341 | if (!policy.contentType.trim()) { 342 | policy.contentTypeError = 'Please fill in a Content-Type.'; 343 | } 344 | valid &= !policy.contentTypeError; 345 | } 346 | }); 347 | 348 | // Update the fields and policies to trigger a re-render. 349 | await this.model.updateFields(); 350 | await this.model.updatePolicies(); 351 | 352 | return valid; 353 | } 354 | 355 | @action 356 | async updateBinding(element, event) { 357 | this.model.error = null; 358 | element.error = ''; 359 | 360 | const result = await this.expandBinding(event.target.value); 361 | 362 | if (result.error) { 363 | element.error = result.error; 364 | } else { 365 | element.property = result.binding; 366 | } 367 | 368 | // Update the fields to trigger a re-render. 369 | await this.model.updateFields(); 370 | } 371 | 372 | @action 373 | async updateFormBinding() { 374 | this.model.error = null; 375 | this.model.formTargetClassError = ''; 376 | 377 | const result = await this.expandBinding(this.model.formTargetClass); 378 | if (result.error) { 379 | this.model.formTargetClassError = result.error; 380 | } else { 381 | this.model.formTargetClass = result.binding; 382 | } 383 | } 384 | 385 | @action 386 | async updateChoiceBinding(element, event) { 387 | this.model.error = null; 388 | element.choiceError = ''; 389 | 390 | const result = await this.expandBinding(event.target.value); 391 | 392 | if (result.error) { 393 | element.choiceError = result.error; 394 | } else { 395 | element.choice = result.binding; 396 | } 397 | 398 | // Update the fields to trigger a re-render. 399 | await this.model.updateFields(); 400 | } 401 | 402 | async expandBinding(binding_) { 403 | let binding = binding_?.trim(); 404 | 405 | if (!binding) { 406 | return { error: 'Please fill in a binding.' }; 407 | } 408 | 409 | if (binding.includes(':')) { 410 | if (!binding.includes('://')) { 411 | binding = await this.replacePrefixInBinding(binding); 412 | if (!binding) { 413 | return { error: 'Please fill in a valid binding.' }; 414 | } 415 | } 416 | } else { 417 | return { error: 'Please fill in a valid binding.' }; 418 | } 419 | return { binding }; 420 | } 421 | 422 | async replacePrefixInBinding(binding) { 423 | // Do call to prefix.cc to get the full URI 424 | const [prefix, suffix] = binding.split(':'); 425 | const response = await fetch( 426 | `https://prefixcc-proxy.smessie.com/${prefix}.file.json`, 427 | ); 428 | const json = await response.json(); 429 | const uri = json[prefix]; 430 | if (uri) { 431 | binding = uri + suffix; 432 | } else { 433 | this.model.error = `Could not find a prefix for '${prefix}'!`; 434 | return undefined; 435 | } 436 | return binding; 437 | } 438 | 439 | @action 440 | async addOption(field, event) { 441 | event.preventDefault(); 442 | event.target.closest('.btn').disabled = true; 443 | 444 | const baseUri = this.model.loadedFormUri.split('#')[0]; 445 | let option = { uuid: uuid(), label: '', property: '' }; 446 | if ( 447 | this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' 448 | ) { 449 | option = { 450 | ...option, 451 | uri: `${baseUri}#${uuid()}`, 452 | listSubject: `${baseUri}#${uuid()}`, 453 | }; 454 | } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { 455 | option = { 456 | ...option, 457 | listSubject: `${baseUri}#${uuid()}`, 458 | }; 459 | } 460 | field.options.push(option); 461 | 462 | // Update the fields to trigger a re-render. 463 | await this.model.updateFields(); 464 | 465 | event.target.closest('.btn').disabled = false; 466 | } 467 | 468 | @action 469 | async removeOption(field, option, event) { 470 | event.preventDefault(); 471 | field.options = field.options.filter((o) => o.uuid !== option.uuid); 472 | // Update the fields to trigger a re-render. 473 | await this.model.updateFields(); 474 | } 475 | 476 | @action 477 | removeField(field, event) { 478 | event?.preventDefault(); 479 | this.model.fields = this.model.fields.filter((f) => f.uuid !== field.uuid); 480 | } 481 | 482 | @action 483 | changeVocabulary(event) { 484 | // Clear any existing form. 485 | this.model.clearForm(); 486 | 487 | // Update the vocabulary. 488 | this.model.vocabulary = event.target.value; 489 | } 490 | 491 | getSolidUiRdfTypeFromWidgetType(type) { 492 | if (type === 'string') { 493 | return 'http://www.w3.org/ns/ui#SingleLineTextField'; 494 | } else if (type === 'textarea') { 495 | return 'http://www.w3.org/ns/ui#MultiLineTextField'; 496 | } else if (type === 'dropdown') { 497 | return 'http://www.w3.org/ns/ui#Choice'; 498 | } else if (type === 'date') { 499 | return 'http://www.w3.org/ns/ui#DateField'; 500 | } else if (type === 'checkbox') { 501 | return 'http://www.w3.org/ns/ui#BooleanField'; 502 | } else { 503 | return null; 504 | } 505 | } 506 | 507 | getShaclDatatypeOrNodeKindFromWidgetType(type) { 508 | if (type === 'string') { 509 | return { type: 'http://www.w3.org/2001/XMLSchema#string' }; 510 | } else if (type === 'textarea') { 511 | return { type: 'http://www.w3.org/2001/XMLSchema#string' }; 512 | } else if (type === 'dropdown') { 513 | return { nodeKind: 'http://www.w3.org/ns/shacl#IRI' }; 514 | } else if (type === 'date') { 515 | return { type: 'http://www.w3.org/2001/XMLSchema#date' }; 516 | } else if (type === 'checkbox') { 517 | return { type: 'http://www.w3.org/2001/XMLSchema#boolean' }; 518 | } else { 519 | return null; 520 | } 521 | } 522 | 523 | @action 524 | clearSuccess() { 525 | this.model.success = null; 526 | } 527 | 528 | @action 529 | clearError() { 530 | this.model.error = null; 531 | } 532 | 533 | @action 534 | clearInfo() { 535 | this.model.info = null; 536 | } 537 | 538 | @action 539 | async loadForm(event) { 540 | event.preventDefault(); 541 | document.getElementById('load-btn').disabled = true; 542 | document.getElementById('load-btn').innerText = 'Loading...'; 543 | 544 | this.form = this.model.loadedFormUri; 545 | 546 | await this.model.loadForm(this.form); 547 | 548 | document.getElementById('load-btn').disabled = false; 549 | document.getElementById('load-btn').innerText = 'Load'; 550 | } 551 | 552 | async isEventSubmitRule(rule, prefixes) { 553 | const options = { outputType: 'string' }; 554 | const query = `${prefixes ? prefixes.join('\n') : ''}\n${rule}`; 555 | const reasonerResult = await n3reasoner( 556 | `<${this.model.loadedFormUri}> .`, 557 | query, 558 | options, 559 | ); 560 | return reasonerResult.length > 0; 561 | } 562 | 563 | @action 564 | updatePolicyMethod(policy, event) { 565 | policy.method = event.target.value?.trim(); 566 | } 567 | 568 | @action 569 | addPolicy(type) { 570 | if (type && type.startsWith('policy-')) { 571 | let policy = { uuid: uuid(), url: '' }; 572 | if (type === 'policy-redirect') { 573 | policy.executionTarget = 'https://w3id.org/DFDP/policy#Redirect'; 574 | } else if (type === 'policy-n3-patch') { 575 | policy.executionTarget = 576 | 'http://www.w3.org/ns/solid/terms#InsertDeletePatch'; 577 | } else if (type === 'policy-http-request') { 578 | policy = { 579 | ...policy, 580 | method: 'POST', 581 | contentType: '', 582 | executionTarget: 'http://www.w3.org/2011/http#Request', 583 | }; 584 | } 585 | this.model.policies = [...this.model.policies, policy]; 586 | } 587 | } 588 | 589 | @action 590 | removePolicy(policy, event) { 591 | event?.preventDefault(); 592 | this.model.policies = this.model.policies.filter( 593 | (p) => p.uuid !== policy.uuid, 594 | ); 595 | } 596 | 597 | @action 598 | async login() { 599 | await handleIncomingRedirect(); 600 | 601 | // 2. Start the Login Process if not already logged in. 602 | if (!getDefaultSession().info.isLoggedIn) { 603 | await login({ 604 | // Specify the URL of the user's Solid Identity Provider; 605 | // e.g., "https://login.inrupt.com". 606 | oidcIssuer: this.oidcIssuer, 607 | // Specify the URL the Solid Identity Provider should redirect the user once logged in, 608 | // e.g., the current page for a single-page app. 609 | redirectUrl: window.location.href, 610 | // Provide a name for the application when sending to the Solid Identity Provider 611 | clientName: 'FormGenerator', 612 | }).catch((e) => { 613 | this.authError = e.message; 614 | }); 615 | } 616 | } 617 | 618 | @action 619 | async logout() { 620 | await logout(); 621 | this.model.loggedIn = undefined; 622 | } 623 | 624 | /** 625 | * Stringifies the fields. 626 | * Requires the following prefixes to be defined: 627 | * @prefix ui: . 628 | * @prefix form: . 629 | * @prefix sh: . 630 | * @prefix rdf: . 631 | * @prefix rdfs: . 632 | * @prefix owl: . 633 | * @prefix xsd: . 634 | * @prefix skos: . 635 | */ 636 | stringifyFields(formUri, targetClass, fields) { 637 | if ( 638 | this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' 639 | ) { 640 | return this.stringifyRdfFormFields(formUri, targetClass, fields); 641 | } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { 642 | return this.stringifySoldUiFields(formUri, targetClass, fields); 643 | } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { 644 | return this.stringifyShaclFields(formUri, targetClass, fields); 645 | } else { 646 | console.error('Unknown vocabulary', this.model.vocabulary); 647 | return ''; 648 | } 649 | } 650 | 651 | /** 652 | * Stringifies the fields in the Solid-UI vocabulary. 653 | * Requires the following prefixes to be defined: 654 | * @prefix ui: . 655 | * @prefix xsd: . 656 | * @prefix skos: . 657 | */ 658 | stringifySoldUiFields(formUri, targetClass, fields) { 659 | let data = ''; 660 | for (const field of fields) { 661 | data += `<${field.uri}> a <${field.type}> .\n`; 662 | data += `<${field.uri}> ui:property <${field.property}> .\n`; 663 | if (field.label) { 664 | data += `<${field.uri}> ui:label "${field.label}" .\n`; 665 | } 666 | if (field.required !== undefined) { 667 | data += `<${field.uri}> ui:required "${field.required}"^^xsd:boolean .\n`; 668 | } 669 | if (field.multiple !== undefined) { 670 | data += `<${field.uri}> ui:multiple "${field.multiple}"^^xsd:boolean .\n`; 671 | } 672 | if (field.order !== undefined) { 673 | data += `<${field.uri}> ui:sequence ${field.order} .\n`; 674 | } 675 | if (field.choice) { 676 | data += `<${field.uri}> ui:from <${field.choice}> .\n`; 677 | 678 | // Stringify the options. 679 | for (const option of field.options) { 680 | data += `<${option.property}> a <${field.choice}> .\n`; 681 | if (option.label) { 682 | data += `<${option.property}> skos:prefLabel "${option.label}" .\n`; 683 | } 684 | } 685 | } 686 | } 687 | return data; 688 | } 689 | 690 | /** 691 | * Stringifies the fields in the SHACL vocabulary. 692 | * Requires the following prefixes to be defined: 693 | * @prefix sh: . 694 | * @prefix rdf: . 695 | * @prefix rdfs: . 696 | * @prefix owl: . 697 | */ 698 | stringifyShaclFields(formUri, targetClass, fields) { 699 | let data = ''; 700 | for (const field of fields) { 701 | data += `<${field.uri}> a sh:PropertyShape .\n`; 702 | if (field.property) { 703 | data += `<${field.uri}> sh:path <${field.property}> .\n`; 704 | } 705 | if (field.type) { 706 | data += `<${field.uri}> sh:datatype <${field.type}> .\n`; 707 | } 708 | if (field.nodeKind) { 709 | data += `<${field.uri}> sh:nodeKind <${field.nodeKind}> .\n`; 710 | } 711 | if (field.minCount !== undefined) { 712 | data += `<${field.uri}> sh:minCount ${field.minCount} .\n`; 713 | } 714 | if (field.maxCount !== undefined) { 715 | data += `<${field.uri}> sh:maxCount ${field.maxCount} .\n`; 716 | } 717 | if (field.label) { 718 | data += `<${field.uri}> sh:name "${field.label}" .\n`; 719 | } 720 | if (field.order !== undefined) { 721 | data += `<${field.uri}> sh:order ${field.order} .\n`; 722 | } 723 | if (field.isSelect) { 724 | data += `<${field.uri}> sh:in ${ 725 | field.options.length ? `<${field.options[0].listSubject}>` : 'rdf:nil' 726 | } .\n`; 727 | 728 | // Stringify the options. 729 | for (const option of field.options) { 730 | data += `<${option.property}> a owl:Class .\n`; 731 | if (option.label) { 732 | data += `<${option.property}> rdfs:label "${option.label}" .\n`; 733 | } 734 | } 735 | 736 | // Stringify RDF List. 737 | for (const [index, option] of field.options.entries()) { 738 | data += `<${option.listSubject}> rdf:first <${option.property}> .\n`; 739 | data += `<${option.listSubject}> rdf:rest ${ 740 | index === field.options.length - 1 741 | ? 'rdf:nil' 742 | : `<${field.options[index + 1].listSubject}>` 743 | } .\n`; 744 | } 745 | } 746 | } 747 | return data; 748 | } 749 | 750 | /** 751 | * Stringifies the fields in the RDF Form vocabulary. 752 | * Requires the following prefixes to be defined: 753 | * @prefix form: . 754 | * @prefix xsd: . 755 | * @prefix rdf: . 756 | */ 757 | stringifyRdfFormFields(formUri, targetClass, fields) { 758 | let data = ''; 759 | for (const field of fields) { 760 | data += `<${field.uri}> a form:Field .\n`; 761 | data += `<${field.uri}> form:widget "${field.type}" .\n`; 762 | if (field.property) { 763 | data += `<${field.uri}> form:binding <${field.property}> .\n`; 764 | } 765 | if (field.label) { 766 | data += `<${field.uri}> form:label "${field.label}" .\n`; 767 | } 768 | if (field.order !== undefined) { 769 | data += `<${field.uri}> form:order ${field.order} .\n`; 770 | } 771 | if (field.required !== undefined) { 772 | data += `<${field.uri}> form:required "${field.required}"^^xsd:boolean .\n`; 773 | } 774 | if (field.multiple !== undefined) { 775 | data += `<${field.uri}> form:multiple "${field.multiple}"^^xsd:boolean .\n`; 776 | } 777 | if (field.placeholder) { 778 | data += `<${field.uri}> form:placeholder "${field.placeholder}" .\n`; 779 | } 780 | if (field.isSelect) { 781 | data += `<${field.uri}> form:option ${ 782 | field.options.length ? `<${field.options[0].listSubject}>` : 'rdf:nil' 783 | } .\n`; 784 | 785 | // Stringify the options. 786 | for (const option of field.options) { 787 | if (option.property) { 788 | data += `<${option.uri}> form:value <${option.property}> .\n`; 789 | } 790 | if (option.label) { 791 | data += `<${option.uri}> form:label "${option.label}" .\n`; 792 | } 793 | } 794 | 795 | // Stringify RDF List. 796 | for (const [index, option] of field.options.entries()) { 797 | data += `<${option.listSubject}> rdf:first <${option.uri}> .\n`; 798 | data += `<${option.listSubject}> rdf:rest ${ 799 | index === field.options.length - 1 800 | ? 'rdf:nil' 801 | : `<${field.options[index + 1].listSubject}>` 802 | } .\n`; 803 | } 804 | } 805 | } 806 | return data; 807 | } 808 | 809 | /** 810 | * Stringifies the form subject. 811 | * Requires the following prefixes to be defined: 812 | * @prefix ui: . 813 | * @prefix form: . 814 | * @prefix sh: . 815 | * @prefix rdf: . 816 | */ 817 | stringifyFormSubject(formUri, targetClass, fields) { 818 | if ( 819 | this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' 820 | ) { 821 | return this.stringifyRdfFormSubject(formUri, targetClass); 822 | } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { 823 | return this.stringifySolidUiFormSubject(formUri, targetClass, fields); 824 | } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { 825 | return this.stringifyShaclFormSubject(formUri, targetClass, fields); 826 | } else { 827 | console.error('Unknown vocabulary', this.model.vocabulary); 828 | return ''; 829 | } 830 | } 831 | 832 | /** 833 | * Stringifies the form subject in the Solid-UI vocabulary. 834 | * Requires the following prefixes to be defined: 835 | * @prefix ui: . 836 | * @prefix rdf: . 837 | */ 838 | stringifySolidUiFormSubject(formUri, targetClass, fields) { 839 | let data = `<${formUri}> a ui:Form .\n`; 840 | if (targetClass) { 841 | data += `<${formUri}> ui:property <${targetClass}> .\n`; 842 | } 843 | data += `<${formUri}> ui:parts ${ 844 | fields.length ? `<${fields[0].listSubject}>` : 'rdf:nil' 845 | } .\n`; 846 | for (const [index, field] of fields.entries()) { 847 | data += `<${field.listSubject}> rdf:first <${field.uri}> .\n`; 848 | data += `<${field.listSubject}> rdf:rest ${ 849 | index === fields.length - 1 850 | ? 'rdf:nil' 851 | : `<${fields[index + 1].listSubject}>` 852 | } .\n`; 853 | } 854 | return data; 855 | } 856 | 857 | /** 858 | * Stringifies the form subject in the SHACL vocabulary. 859 | * Requires the following prefixes to be defined: 860 | * @prefix sh: . 861 | */ 862 | stringifyShaclFormSubject(formUri, targetClass, fields) { 863 | let data = `<${formUri}> a sh:NodeShape .\n`; 864 | if (targetClass) { 865 | data += `<${formUri}> sh:targetClass <${targetClass}> .\n`; 866 | } 867 | for (const field of fields) { 868 | data += `<${formUri}> sh:property <${field.uri}> .\n`; 869 | } 870 | return data; 871 | } 872 | 873 | /** 874 | * Stringifies the form subject in the RDF Form vocabulary. 875 | * Requires the following prefixes to be defined: 876 | * @prefix form: . 877 | */ 878 | stringifyRdfFormSubject(formUri, targetClass) { 879 | let data = `<${formUri}> a form:Form .\n`; 880 | if (targetClass) { 881 | data += `<${formUri}> form:binding <${targetClass}> .\n`; 882 | } 883 | return data; 884 | } 885 | 886 | stringifyPolicies(policies) { 887 | const list = []; 888 | for (const policy of policies) { 889 | // Add basic properties and N3 rule syntax equal for all policy types. 890 | let data = ` 891 | { 892 | <${this.model.loadedFormUri}> pol:event pol:Submit. 893 | } => { 894 | ex:HttpPolicy pol:policy [ 895 | a fno:Execution ; 896 | fno:executes <${policy.executionTarget}> ; 897 | http:requestURI <${policy.url}>`; 898 | 899 | // If the policy is a HTTP request, add the method and content type. 900 | if (policy.executionTarget === 'http://www.w3.org/2011/http#Request') { 901 | data += ` ; 902 | http:methodName "${policy.method}" ; 903 | http:headers ( 904 | [ http:fieldName "Content-Type" ; http:fieldValue "${policy.contentType}" ] 905 | )`; 906 | } 907 | 908 | // Finish the policy syntax. 909 | data += ` 910 | ] . 911 | } . 912 | `; 913 | 914 | list.push(data); 915 | } 916 | return list; 917 | } 918 | 919 | optionsAreEqual(a, b) { 920 | if (!a) { 921 | return !b; 922 | } 923 | if (a.length !== b.length) { 924 | return false; 925 | } 926 | for (const option of a) { 927 | const equivalentOption = b.find((o) => o.uuid === option.uuid); 928 | if (!equivalentOption) { 929 | return false; 930 | } 931 | if ( 932 | option.property !== equivalentOption.property || 933 | option.label !== equivalentOption.label || 934 | option.uri !== equivalentOption.uri 935 | ) { 936 | return false; 937 | } 938 | } 939 | return true; 940 | } 941 | 942 | policiesAreEqual(a, b) { 943 | if (!a) { 944 | return !b; 945 | } 946 | if (a.length !== b.length) { 947 | return false; 948 | } 949 | for (const policy of a) { 950 | const equivalentPolicy = b.find((p) => p.uuid === policy.uuid); 951 | if (!equivalentPolicy) { 952 | return false; 953 | } 954 | if ( 955 | policy.url !== equivalentPolicy.url || 956 | policy.executionTarget !== equivalentPolicy.executionTarget || 957 | policy.method !== equivalentPolicy.method || 958 | policy.contentType !== equivalentPolicy.contentType 959 | ) { 960 | return false; 961 | } 962 | } 963 | return true; 964 | } 965 | } 966 | -------------------------------------------------------------------------------- /app/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/app/helpers/.gitkeep -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FormGenerator 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | 11 | 12 | 13 | 14 | {{content-for "head-footer"}} 15 | 16 | 17 | {{content-for "body"}} 18 | 19 | 20 | 21 | 22 | {{content-for "body-footer"}} 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/app/models/.gitkeep -------------------------------------------------------------------------------- /app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from 'form-generator/config/environment'; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | rootURL = config.rootURL; 7 | } 8 | 9 | Router.map(function () { 10 | this.route('login'); 11 | }); 12 | -------------------------------------------------------------------------------- /app/routes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/app/routes/.gitkeep -------------------------------------------------------------------------------- /app/routes/application.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import { service } from '@ember/service'; 3 | 4 | export default class ApplicationRoute extends Route { 5 | @service solidAuth; 6 | 7 | async beforeModel() { 8 | await this.solidAuth.restoreSession(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import { v4 as uuid } from 'uuid'; 3 | import { tracked } from '@glimmer/tracking'; 4 | import { n3reasoner } from 'eyereasoner'; 5 | import { QueryEngine } from '@comunica/query-sparql'; 6 | import { fetch } from '@smessie/solid-client-authn-browser'; 7 | import { findStorageRoot } from 'solid-storage-root'; 8 | import { service } from '@ember/service'; 9 | 10 | export default class IndexRoute extends Route { 11 | queryParams = { 12 | form: { 13 | refreshModel: true, 14 | }, 15 | }; 16 | 17 | @service solidAuth; 18 | 19 | @tracked loadedFormUri; 20 | 21 | @tracked vocabulary = 'http://www.w3.org/ns/shacl#'; 22 | 23 | engine = new QueryEngine(); 24 | 25 | originalFields = []; 26 | originalPolicies = []; 27 | originalFormTargetClass = ''; 28 | @tracked fields = []; 29 | @tracked policies = []; 30 | @tracked formTargetClass; 31 | @tracked formTargetClassError = ''; 32 | 33 | newForm = true; 34 | 35 | @tracked success = null; 36 | @tracked error = null; 37 | @tracked info = null; 38 | 39 | async model({ form }) { 40 | if (form) { 41 | const loadedForm = this.loadForm(form); 42 | if (loadedForm) { 43 | this.loadedFormUri = form; 44 | } 45 | } else { 46 | this.newForm = true; 47 | if (this.solidAuth.loggedIn) { 48 | const storageRoot = await findStorageRoot( 49 | this.solidAuth.loggedIn, 50 | fetch, 51 | ); 52 | this.loadedFormUri = `${storageRoot}private/tests/forms/${uuid()}.n3#${uuid()}`; 53 | } else { 54 | this.info = 55 | 'Log in to use a random location in your Solid pod or manually provide a form URI to get started.'; 56 | } 57 | } 58 | return this; 59 | } 60 | 61 | async loadForm(formUri) { 62 | this.clearForm(); 63 | 64 | if (!formUri) { 65 | this.newForm = true; 66 | return false; 67 | } 68 | await this.loadPolicies(formUri); 69 | 70 | if (await this.loadSolidUiForm(formUri)) { 71 | this.vocabulary = 'http://www.w3.org/ns/ui#'; 72 | } else if (await this.loadShaclForm(formUri)) { 73 | this.vocabulary = 'http://www.w3.org/ns/shacl#'; 74 | } else if (await this.loadRdfFormForm(formUri)) { 75 | this.vocabulary = 'http://rdf.danielbeeke.nl/form/form-dev.ttl#'; 76 | } else { 77 | this.newForm = true; 78 | return false; 79 | } 80 | this.newForm = false; 81 | this.originalFields = JSON.parse(JSON.stringify(this.fields)); 82 | this.originalPolicies = JSON.parse(JSON.stringify(this.policies)); 83 | this.originalFormTargetClass = this.formTargetClass; 84 | console.log('Form loaded: ', this.fields); 85 | return true; 86 | } 87 | 88 | async loadPolicies(formUri) { 89 | // Get resource content. 90 | const response = await fetch(formUri, { 91 | method: 'GET', 92 | }); 93 | if (!response.ok) { 94 | this.error = 'Could not load form.'; 95 | return; 96 | } 97 | const content = await response.text(); 98 | 99 | // Get policies from footprint tasks. 100 | const options = { outputType: 'string' }; 101 | const reasonerResult = await n3reasoner( 102 | `<${formUri}> .`, 103 | content, 104 | options, 105 | ); 106 | 107 | // Parse policies. 108 | const queryPolicy = ` 109 | PREFIX ex: 110 | PREFIX pol: 111 | PREFIX fno: 112 | PREFIX http: 113 | 114 | SELECT ?executionTarget ?method ?url ?contentType WHERE { 115 | ?id pol:policy ?policy . 116 | ?policy a fno:Execution . 117 | ?policy fno:executes ?executionTarget . 118 | ?policy http:requestURI ?url . 119 | OPTIONAL { ?policy http:methodName ?method } . 120 | OPTIONAL { ?policy http:headers ( [ http:fieldName "Content-Type" ; http:fieldValue ?contentType ] ) } . 121 | } 122 | `; 123 | const bindings = await ( 124 | await this.engine.queryBindings(queryPolicy, { 125 | sources: [ 126 | { 127 | type: 'stringSource', 128 | value: reasonerResult, 129 | mediaType: 'text/turtle', 130 | baseIRI: formUri.split('#')[0], 131 | }, 132 | ], 133 | }) 134 | ).toArray(); 135 | 136 | this.policies = bindings.map((row) => { 137 | return { 138 | uuid: uuid(), 139 | executionTarget: row.get('executionTarget').value, 140 | url: row.get('url').value, 141 | method: row.get('method')?.value, 142 | contentType: row.get('contentType')?.value, 143 | }; 144 | }); 145 | } 146 | 147 | clearForm() { 148 | this.fields = []; 149 | this.policies = []; 150 | this.originalFields = []; 151 | this.originalPolicies = []; 152 | this.formTargetClass = ''; 153 | this.originalFormTargetClass = ''; 154 | this.formTargetClassError = ''; 155 | this.success = null; 156 | this.error = null; 157 | } 158 | 159 | async removeN3RulesFromResource() { 160 | const response = await fetch( 161 | new URL(this.loadedFormUri /*, await this.solidAuth.podBase*/).href, 162 | { 163 | method: 'GET', 164 | }, 165 | ); 166 | 167 | if (!response.ok) { 168 | return { rules: [], prefixes: [] }; 169 | } 170 | 171 | // Get content-type. 172 | const contentType = response.headers.get('content-type'); 173 | 174 | // Get content. 175 | let text = await response.text(); 176 | 177 | // Match prefixes. 178 | const prefixRegex = /(@prefix|PREFIX)\s+[^:]*:\s*<[^>]*>\s*\.?\n/g; 179 | const prefixes = text.match(prefixRegex); 180 | 181 | // Match N3 rules. 182 | const rulesRegex = 183 | /\{[^{}]*}\s*(=>|[^\s{}:]*:implies|)\s*{[^{}]*}\s*\./g; 184 | const rules = text.match(rulesRegex); 185 | 186 | // Remove N3 rules. 187 | rules?.forEach((match) => { 188 | text = text.replace(match, ''); 189 | }); 190 | 191 | // Save resource without N3 rules. 192 | await fetch(this.loadedFormUri, { 193 | method: 'PUT', 194 | headers: { 195 | 'Content-Type': contentType, 196 | }, 197 | body: text, 198 | }); 199 | 200 | return { rules: rules || [], prefixes: prefixes || [] }; 201 | } 202 | 203 | async addN3RulesToResource(matches) { 204 | const { rules, prefixes } = matches; 205 | 206 | if (!rules) { 207 | // No rules to add. 208 | return true; 209 | } 210 | 211 | const response = await fetch(this.loadedFormUri, { 212 | method: 'GET', 213 | }); 214 | 215 | if (!response.ok) { 216 | return false; 217 | } 218 | 219 | // Get content. 220 | let text = await response.text(); 221 | 222 | // Match prefixes. 223 | const prefixRegex = /(@prefix|PREFIX)\s+[^:]*:\s*<[^>]*>\s*\.?\n/g; 224 | const matchedPrefixes = text.match(prefixRegex); 225 | 226 | // Add prefixes in front if not already present. 227 | prefixes?.forEach((prefix) => { 228 | if (!matchedPrefixes?.includes(prefix)) { 229 | text = prefix + '\n' + text; 230 | } 231 | }); 232 | 233 | // Add N3 rules. 234 | rules?.forEach((match) => { 235 | text += match + '\n'; 236 | }); 237 | 238 | // Save resource with N3 rules. 239 | const response2 = await fetch(this.loadedFormUri, { 240 | method: 'PUT', 241 | headers: { 242 | 'Content-Type': 'text/n3', 243 | }, 244 | body: text, 245 | }); 246 | 247 | return response2.ok; 248 | } 249 | 250 | async loadSolidUiForm(uri) { 251 | const query = ` 252 | PREFIX ui: 253 | PREFIX rdf: 254 | 255 | SELECT ?targetClass ?type ?field ?property ?label ?from ?required ?multiple ?sequence ?listSubject 256 | WHERE { 257 | <${uri}> a ui:Form; 258 | ui:parts ?list ; 259 | ui:property ?targetClass . 260 | ?list rdf:rest*/rdf:first ?field . 261 | ?listSubject rdf:first ?field . 262 | ?field a ?type; 263 | ui:property ?property. 264 | OPTIONAL { ?field ui:label ?label. } 265 | OPTIONAL { ?field ui:from ?from. } 266 | OPTIONAL { ?field ui:required ?required. } 267 | OPTIONAL { ?field ui:multiple ?multiple. } 268 | OPTIONAL { ?field ui:sequence ?sequence. } 269 | } 270 | `; 271 | 272 | const bindings = await ( 273 | await this.engine.queryBindings(query, { sources: [uri], fetch }) 274 | ).toArray(); 275 | 276 | if (!bindings.length) { 277 | return false; 278 | } 279 | 280 | let formTargetClass; 281 | const fields = bindings.map((row) => { 282 | formTargetClass = row.get('targetClass').value; 283 | return { 284 | uuid: uuid(), 285 | uri: row.get('field').value, 286 | type: row.get('type').value, 287 | widget: this.solidUiTypeToWidget(row.get('type').value), 288 | property: row.get('property').value, 289 | label: row.get('label')?.value, 290 | choice: row.get('from')?.value, 291 | required: row.get('required')?.value === 'true', 292 | multiple: row.get('multiple')?.value === 'true', 293 | order: parseInt(row.get('sequence')?.value), 294 | listSubject: row.get('listSubject').value, 295 | canHavePlaceholder: false, 296 | canHaveChoiceBinding: true, 297 | }; 298 | }); 299 | 300 | // Sort fields by order 301 | fields.sort((a, b) => a.order - b.order); 302 | 303 | // Add options to Choice fields 304 | for (const field of fields) { 305 | if (field.type === 'http://www.w3.org/ns/ui#Choice') { 306 | field.options = []; 307 | field.isSelect = true; 308 | const query = ` 309 | PREFIX ui: 310 | PREFIX rdf: 311 | PREFIX skos: 312 | SELECT ?value ?label WHERE { 313 | ?value a <${field.choice}> . 314 | OPTIONAL { ?value skos:prefLabel ?label . } 315 | } 316 | `; 317 | 318 | const bindings = await ( 319 | await this.engine.queryBindings(query, { sources: [uri], fetch }) 320 | ).toArray(); 321 | 322 | field.options = bindings.map((row) => { 323 | return { 324 | uuid: uuid(), 325 | property: row.get('value').value, 326 | label: row.get('label')?.value, 327 | }; 328 | }); 329 | } 330 | } 331 | 332 | this.formTargetClass = formTargetClass; 333 | this.fields = fields; 334 | 335 | return true; 336 | } 337 | 338 | async loadShaclForm(uri) { 339 | const query = ` 340 | PREFIX sh: 341 | 342 | SELECT ?targetClass ?type ?field ?nodeKind ?property ?label ?order ?minCount ?maxCount ?in 343 | WHERE { 344 | <${uri}> a sh:NodeShape; 345 | sh:targetClass ?targetClass ; 346 | sh:property ?field . 347 | ?field a sh:PropertyShape . 348 | OPTIONAL { ?field sh:datatype ?type . } 349 | OPTIONAL { ?field sh:nodeKind ?nodeKind . } 350 | OPTIONAL { ?field sh:path ?property . } 351 | OPTIONAL { ?field sh:name ?label . } 352 | OPTIONAL { ?field sh:order ?order . } 353 | OPTIONAL { ?field sh:minCount ?minCount . } 354 | OPTIONAL { ?field sh:maxCount ?maxCount . } 355 | OPTIONAL { ?field sh:in ?in . } 356 | }`; 357 | 358 | const bindings = await ( 359 | await this.engine.queryBindings(query, { sources: [uri], fetch }) 360 | ).toArray(); 361 | 362 | console.log('bindings', bindings); 363 | 364 | if (!bindings.length) { 365 | return false; 366 | } 367 | 368 | let formTargetClass; 369 | const fields = bindings.map((row) => { 370 | formTargetClass = row.get('targetClass').value; 371 | return { 372 | uuid: uuid(), 373 | uri: row.get('field').value, 374 | type: row.get('type')?.value, 375 | widget: this.shaclTypeToWidget( 376 | row.get('type')?.value || row.get('nodeKind')?.value, 377 | ), 378 | nodeKind: row.get('nodeKind')?.value, 379 | property: row.get('property')?.value, 380 | label: row.get('label')?.value, 381 | order: parseInt(row.get('order')?.value), 382 | minCount: parseInt(row.get('minCount')?.value), 383 | maxCount: parseInt(row.get('maxCount')?.value), 384 | in: row.get('in')?.value, 385 | canHavePlaceholder: false, 386 | canHaveChoiceBinding: false, 387 | }; 388 | }); 389 | 390 | // Sort fields by order 391 | fields.sort((a, b) => a.order - b.order); 392 | 393 | // Add options to Choice fields (in case of sh:in) 394 | for (const field of fields) { 395 | if (field.in) { 396 | field.options = []; 397 | field.isSelect = true; 398 | const query = ` 399 | PREFIX rdf: 400 | PREFIX owl: 401 | PREFIX rdfs: 402 | 403 | SELECT ?option ?label ?listSubject 404 | WHERE { 405 | <${field.in}> rdf:rest*/rdf:first ?option . 406 | ?listSubject rdf:first ?option . 407 | ?option a owl:Class . 408 | OPTIONAL { ?option rdfs:label ?label . } 409 | }`; 410 | 411 | const bindings = await ( 412 | await this.engine.queryBindings(query, { sources: [uri], fetch }) 413 | ).toArray(); 414 | 415 | field.options = bindings.map((row) => { 416 | return { 417 | uuid: uuid(), 418 | property: row.get('option').value, 419 | label: row.get('label')?.value, 420 | listSubject: row.get('listSubject').value, 421 | }; 422 | }); 423 | } 424 | } 425 | 426 | this.formTargetClass = formTargetClass; 427 | this.fields = fields; 428 | 429 | return true; 430 | } 431 | 432 | async loadRdfFormForm(uri) { 433 | const query = ` 434 | PREFIX form: 435 | PREFIX rdf: 436 | 437 | SELECT ?targetClass ?field ?type ?property ?label ?order ?required ?multiple ?placeholder 438 | WHERE { 439 | <${uri}> a form:Form; 440 | form:binding ?targetClass . 441 | ?field a form:Field; 442 | form:widget ?type . 443 | OPTIONAL { ?field form:binding ?property. } 444 | OPTIONAL { ?field form:label ?label. } 445 | OPTIONAL { ?field form:order ?order. } 446 | OPTIONAL { ?field form:required ?required. } 447 | OPTIONAL { ?field form:multiple ?multiple. } 448 | OPTIONAL { ?field form:placeholder ?placeholder. } 449 | }`; 450 | 451 | const bindings = await ( 452 | await this.engine.queryBindings(query, { sources: [uri], fetch }) 453 | ).toArray(); 454 | 455 | if (!bindings.length) { 456 | return false; 457 | } 458 | 459 | let formTargetClass; 460 | const fields = bindings.map((row) => { 461 | formTargetClass = row.get('targetClass').value; 462 | return { 463 | uuid: uuid(), 464 | uri: row.get('field').value, 465 | type: row.get('type').value, 466 | widget: row.get('type').value, 467 | property: row.get('property')?.value, 468 | label: row.get('label')?.value, 469 | order: parseInt(row.get('order')?.value), 470 | required: row.get('required')?.value === 'true', 471 | multiple: row.get('multiple')?.value === 'true', 472 | placeholder: row.get('placeholder')?.value, 473 | canHavePlaceholder: 474 | row.get('type').value === 'string' || 475 | row.get('type').value === 'textarea', 476 | canHaveChoiceBinding: false, 477 | }; 478 | }); 479 | 480 | // Sort fields by order 481 | fields.sort((a, b) => a.order - b.order); 482 | 483 | // Add options to Choice fields (in case of type = "dropdown") 484 | for (const field of fields) { 485 | if (field.type === 'dropdown') { 486 | field.options = []; 487 | field.isSelect = true; 488 | const query = ` 489 | PREFIX form: 490 | PREFIX rdf: 491 | 492 | SELECT ?value ?label ?option ?options ?listSubject 493 | WHERE { 494 | <${field.uri}> form:option ?options . 495 | ?options rdf:rest*/rdf:first ?option . 496 | ?listSubject rdf:first ?option . 497 | OPTIONAL { ?option form:value ?value . } 498 | OPTIONAL { ?option form:label ?label . } 499 | } 500 | `; 501 | 502 | const bindings = await ( 503 | await this.engine.queryBindings(query, { sources: [uri], fetch }) 504 | ).toArray(); 505 | 506 | field.options = bindings.map((row) => { 507 | return { 508 | uuid: uuid(), 509 | property: row.get('value')?.value, 510 | label: row.get('label')?.value, 511 | uri: row.get('option').value, 512 | listSubject: row.get('listSubject').value, 513 | }; 514 | }); 515 | } 516 | } 517 | 518 | this.formTargetClass = formTargetClass; 519 | this.fields = fields; 520 | 521 | return true; 522 | } 523 | 524 | solidUiTypeToWidget(type) { 525 | switch (type) { 526 | case 'http://www.w3.org/ns/ui#SingleLineTextField': 527 | return 'string'; 528 | case 'http://www.w3.org/ns/ui#MultiLineTextField': 529 | return 'textarea'; 530 | case 'http://www.w3.org/ns/ui#Choice': 531 | return 'dropdown'; 532 | case 'http://www.w3.org/ns/ui#BooleanField': 533 | return 'checkbox'; 534 | case 'http://www.w3.org/ns/ui#DateField': 535 | return 'date'; 536 | } 537 | } 538 | 539 | shaclTypeToWidget(type) { 540 | switch (type) { 541 | case 'http://www.w3.org/2001/XMLSchema#string': 542 | return 'string'; 543 | case 'http://www.w3.org/ns/shacl#IRI': 544 | return 'dropdown'; 545 | case 'http://www.w3.org/2001/XMLSchema#boolean': 546 | return 'checkbox'; 547 | case 'http://www.w3.org/2001/XMLSchema#date': 548 | return 'date'; 549 | } 550 | } 551 | 552 | async updateFields() { 553 | const fields = this.fields; 554 | this.fields = []; 555 | return new Promise((resolve) => 556 | setTimeout(() => { 557 | this.fields = fields; 558 | resolve(); 559 | }, 0), 560 | ); 561 | } 562 | 563 | async updatePolicies() { 564 | const policies = this.policies; 565 | this.policies = []; 566 | return new Promise((resolve) => 567 | setTimeout(() => { 568 | this.policies = policies; 569 | resolve(); 570 | }, 0), 571 | ); 572 | } 573 | 574 | addIfNotIncluded(prefixes, prefix, url) { 575 | let alreadyIncluded = false; 576 | prefixes.forEach((p) => { 577 | if (p.includes(prefix) && p.includes(url)) { 578 | alreadyIncluded = true; 579 | } 580 | }); 581 | if (!alreadyIncluded) { 582 | prefixes.push(`@prefix ${prefix}: <${url}>.`); 583 | } 584 | return prefixes; 585 | } 586 | } 587 | -------------------------------------------------------------------------------- /app/services/solid-auth.js: -------------------------------------------------------------------------------- 1 | import Service from '@ember/service'; 2 | import { handleIncomingRedirect } from '@smessie/solid-client-authn-browser'; 3 | import { tracked } from '@glimmer/tracking'; 4 | 5 | export default class SolidAuthService extends Service { 6 | @tracked loggedIn; 7 | 8 | async restoreSession() { 9 | // Restore solid session 10 | const info = await handleIncomingRedirect({ 11 | url: window.location.href, 12 | restorePreviousSession: true, 13 | }); 14 | this.loggedIn = info.webId; 15 | console.log('Logged in as ', info.webId, info); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/styles/app.css: -------------------------------------------------------------------------------- 1 | .card { 2 | margin-top: 1em; 3 | } 4 | 5 | .margin-top { 6 | margin-top: 1.5em; 7 | } 8 | 9 | .btn-margin { 10 | margin-top: 1.5em; 11 | } 12 | 13 | .field-title { 14 | margin-bottom: 1.5em; 15 | } 16 | 17 | .margin-bottom { 18 | margin-bottom: 3em; 19 | } 20 | 21 | .fork { 22 | float: right; 23 | margin-top: -1.5em; 24 | } 25 | -------------------------------------------------------------------------------- /app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{page-title "FormGenerator"}} 2 | 3 | {{outlet}} -------------------------------------------------------------------------------- /app/templates/index.hbs: -------------------------------------------------------------------------------- 1 | {{page-title "Index"}} 2 | Fork me on GitHub 6 |
7 |
8 |

Form Generator

9 |
10 | 23 |
24 |
25 | 26 |
27 |
28 |
Authentication
29 | {{#if this.solidAuth.loggedIn}} 30 |

You are authenticated as {{this.solidAuth.loggedIn}}

31 | 32 | {{else}} 33 | 35 | {{#if this.authError }} 36 | {{ this.authError }}
37 | {{/if}} 38 | 39 | {{/if}} 40 |
41 |
42 | 43 |
44 |
45 | 46 | 48 |
49 | When loading a form resource, any changes will be discarded and the 50 | given URI will be loaded. 51 |

Beware! Only the most common field types of the vocabularies are supported. 52 |

53 |
54 | 55 | {{#if this.model.success}} 56 |
57 | {{this.model.success}} 58 | 59 |
60 | {{/if}} 61 | {{#if this.model.error}} 62 |
63 | {{this.model.error}} 64 | 65 |
66 | {{/if}} 67 | {{#if this.model.info}} 68 |
69 | {{this.model.info}} 70 | 71 |
72 | {{/if}} 73 | 74 |
75 |
76 |
77 |
78 |
79 |
Available policies
80 |
Drag and drop them in your policies on the right
81 | 82 |
83 |
84 | 85 | HTTP Request 86 |
87 |
88 |
89 | 90 |
91 |
92 | 93 | Redirect 94 |
95 |
96 |
97 | 98 |
99 |
100 | 101 | N3 Patch 102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | Available form fields 111 |
112 |
Drag and drop them in your form on the right
113 | 114 |
115 |
116 | 117 | Input String 118 |
119 |
120 |
121 | {{#if (this.isEqual this.model.vocabulary "http://www.w3.org/ns/shacl#") }} 122 | {{ else }} 123 | 124 |
125 |
126 | 127 | Textarea 128 |
129 |
130 |
131 | {{/if}} 132 | 133 |
134 |
135 | 136 | Select Dropdown 137 |
138 |
139 |
140 | 141 |
142 |
143 | 144 | Date 145 |
146 |
147 |
148 | 149 |
150 |
151 | 152 | Checkbox 153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
Your new form
164 |
Drag and drop the form fields in this area to reorder them
165 | 166 |
167 | 169 |
170 | 173 | {{#if this.model.formTargetClassError }} 174 | {{ this.model.formTargetClassError }} 175 | {{/if}} 176 |
177 |
178 | 179 |
180 | 181 | {{#each this.model.policies as |policy|}} 182 | 183 |
184 |
185 |
186 | {{#if (this.isEqual policy.executionTarget "http://www.w3.org/2011/http#Request") }} 187 |
188 | 189 | HTTP Request 190 |
191 | {{else if (this.isEqual policy.executionTarget "https://w3id.org/DFDP/policy#Redirect") }} 192 |
193 | 194 | Redirect 195 |
196 | {{else if (this.isEqual policy.executionTarget "http://www.w3.org/ns/solid/terms#InsertDeletePatch") }} 197 |
198 | 199 | N3 Patch 200 |
201 | {{/if}} 202 |
203 | 207 |
208 |
209 | 210 |
211 | 213 |
214 | 217 | {{#if policy.urlError }} 218 | {{ policy.urlError }} 219 | {{/if}} 220 |
221 |
222 | 223 | {{#if (this.isEqual policy.executionTarget "http://www.w3.org/2011/http#Request") }} 224 |
225 | 227 |
228 | 237 | {{#if policy.methodError }} 238 | {{ policy.methodError }} 239 | {{/if}} 240 |
241 |
242 |
243 | 245 |
246 | 249 | {{#if policy.contentTypeError }} 250 | {{ policy.contentTypeError }} 251 | {{/if}} 252 |
253 |
254 | {{/if}} 255 |
256 |
257 | 258 | {{/each}} 259 | 260 | 261 |
262 |
263 | Drag here to add policy 264 |
265 |
266 |
267 | 268 |
269 | 270 | 272 | {{#each this.model.fields as |field|}} 273 | 274 |
275 |
276 |
277 | {{#if (this.isEqual field.widget "string") }} 278 |
279 | 280 | Input String 281 |
282 | {{else if (this.isEqual field.widget "textarea") }} 283 |
284 | 285 | Textarea 286 |
287 | {{else if (this.isEqual field.widget "dropdown")}} 288 |
289 | 290 | Select Dropdown 291 |
292 | {{else if (this.isEqual field.widget "date") }} 293 |
294 | 295 | Date 296 |
297 | {{else if (this.isEqual field.widget "checkbox") }} 298 |
299 | 300 | Checkbox 301 |
302 | {{/if}} 303 |
304 | 308 |
309 |
310 |
311 | 312 |
313 | 316 |
317 |
318 |
319 | 321 |
322 | 326 | {{#if field.error }} 327 | {{ field.error }} 328 | {{/if}} 329 |
330 |
331 | {{#if field.canHavePlaceholder}} 332 |
333 | 335 |
336 | 340 |
341 |
342 | {{/if}} 343 | {{#if (this.isEqual this.model.vocabulary "http://www.w3.org/ns/shacl#") }} 344 |
345 | Min count 346 | 348 | Max count 349 | 351 |
352 | {{ else }} 353 |
354 | 356 | 357 |
358 |
359 | 361 | 362 |
363 | {{/if}} 364 | {{#if field.isSelect}} 365 | {{#if field.canHaveChoiceBinding}} 366 |
367 |
368 | 370 |
371 | 376 | {{#if field.choiceError }} 377 | {{ field.choiceError }} 378 | {{/if}} 379 |
380 |
381 | {{/if}} 382 |
383 |
Options
384 |
385 |
386 | {{#each field.options as |option|}} 387 |
388 | 390 | 394 | 398 |
399 | {{#if option.error }} 400 | {{ option.error }} 401 | {{/if}} 402 | {{/each}} 403 |
404 |
405 | 409 |
410 |
411 | {{/if}} 412 |
413 |
414 |
415 | {{/each}} 416 |
417 | 418 | 419 |
420 |
421 | Drag here to add form field 422 |
423 |
424 |
425 | 426 | 427 |
428 |
429 |
430 |
431 |
432 | {{outlet}} 433 | -------------------------------------------------------------------------------- /config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "packages": [ 4 | { 5 | "name": "ember-cli", 6 | "version": "5.4.1", 7 | "blueprints": [ 8 | { 9 | "name": "app", 10 | "outputRepo": "https://github.com/ember-cli/ember-new-output", 11 | "codemodsSource": "ember-app-codemods-manifest@1", 12 | "isBaseBlueprint": true, 13 | "options": [ 14 | "--ci-provider=github" 15 | ] 16 | } 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (environment) { 4 | const ENV = { 5 | modulePrefix: 'form-generator', 6 | environment, 7 | rootURL: environment === 'production' ? '/FormGenerator/' : '/', 8 | locationType: 'history', 9 | rdfStore: { 10 | name: 'store', 11 | enableDataAdapter: true, // Ember Inspector "Data" tab 12 | }, 13 | 14 | EmberENV: { 15 | EXTEND_PROTOTYPES: false, 16 | FEATURES: { 17 | // Here you can enable experimental features on an ember canary build 18 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 19 | }, 20 | }, 21 | 22 | APP: { 23 | // Here you can pass flags/options to your application instance 24 | // when it is created 25 | }, 26 | }; 27 | 28 | if (environment === 'development') { 29 | // ENV.APP.LOG_RESOLVER = true; 30 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 31 | // ENV.APP.LOG_TRANSITIONS = true; 32 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 33 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 34 | } 35 | 36 | if (environment === 'test') { 37 | // Testem prefers this... 38 | ENV.locationType = 'none'; 39 | 40 | // keep test console output quieter 41 | ENV.APP.LOG_ACTIVE_GENERATION = false; 42 | ENV.APP.LOG_VIEW_LOOKUPS = false; 43 | 44 | ENV.APP.rootElement = '#ember-testing'; 45 | ENV.APP.autoboot = false; 46 | } 47 | 48 | if (environment === 'production') { 49 | // here you can enable a production-specific feature 50 | } 51 | 52 | return ENV; 53 | }; 54 | -------------------------------------------------------------------------------- /config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions', 7 | ]; 8 | 9 | module.exports = { 10 | browsers, 11 | }; 12 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberApp = require('ember-cli/lib/broccoli/ember-app'); 4 | 5 | module.exports = function (defaults) { 6 | let app = new EmberApp(defaults, { 7 | 'ember-bootstrap': { 8 | bootstrapVersion: 5, 9 | importBootstrapCSS: true, 10 | }, 11 | autoImport: { 12 | webpack: { 13 | node: { 14 | global: true, 15 | }, 16 | resolve: { 17 | fallback: { 18 | fs: false, 19 | crypto: false, 20 | path: false, 21 | buffer: require.resolve('buffer/'), 22 | }, 23 | }, 24 | }, 25 | }, 26 | }); 27 | 28 | return app.toTree(); 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "form-generator", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "A form generator app with Solid - Google Forms but the Solid way", 6 | "repository": "https://github.com/SolidLabResearch/FormGenerator", 7 | "license": "MIT", 8 | "author": "Ieben Smessaert", 9 | "directories": { 10 | "doc": "doc", 11 | "test": "tests" 12 | }, 13 | "scripts": { 14 | "build": "ember build --environment=production", 15 | "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"", 16 | "lint:css": "stylelint \"**/*.css\"", 17 | "lint:css:fix": "concurrently \"npm:lint:css -- --fix\"", 18 | "lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\"", 19 | "lint:hbs": "ember-template-lint .", 20 | "lint:hbs:fix": "ember-template-lint . --fix", 21 | "lint:js": "eslint . --cache", 22 | "lint:js:fix": "eslint . --fix", 23 | "start": "ember serve", 24 | "test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"", 25 | "test:ember": "ember test" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.23.6", 29 | "@babel/eslint-parser": "^7.22.15", 30 | "@babel/plugin-transform-class-properties": "^7.23.3", 31 | "@comunica/query-sparql": "^2.10.1", 32 | "@ember/optional-features": "^2.0.0", 33 | "@ember/string": "^3.1.1", 34 | "@ember/test-helpers": "^3.2.0", 35 | "@fortawesome/ember-fontawesome": "^0.4.0", 36 | "@fortawesome/free-solid-svg-icons": "^6.1.2", 37 | "@glimmer/component": "^1.1.2", 38 | "@glimmer/tracking": "^1.1.2", 39 | "@smessie/solid-client-authn-browser": "^1.17.5", 40 | "bootstrap": "^5.2.0", 41 | "broccoli-asset-rev": "^3.0.0", 42 | "buffer": "^6.0.3", 43 | "concurrently": "^8.2.2", 44 | "ember-auto-import": "^2.6.3", 45 | "ember-bootstrap": "^5.1.1", 46 | "ember-cli": "~5.4.1", 47 | "ember-cli-app-version": "^6.0.1", 48 | "ember-cli-babel": "^8.2.0", 49 | "ember-cli-clean-css": "^3.0.0", 50 | "ember-cli-dependency-checker": "^3.3.2", 51 | "ember-cli-htmlbars": "^6.3.0", 52 | "ember-cli-inject-live-reload": "^2.1.0", 53 | "ember-cli-sri": "^2.1.1", 54 | "ember-cli-terser": "^4.0.2", 55 | "ember-drag-drop": "^0.9.0-beta.0", 56 | "ember-fetch": "^8.1.2", 57 | "ember-load-initializers": "^2.1.2", 58 | "ember-modifier": "^4.1.0", 59 | "ember-page-title": "^8.0.0", 60 | "ember-qunit": "^8.0.1", 61 | "ember-resolver": "^11.0.1", 62 | "ember-source": "~5.4.0", 63 | "ember-template-lint": "^5.11.2", 64 | "ember-welcome-page": "^7.0.2", 65 | "eslint": "^8.52.0", 66 | "eslint-config-prettier": "^9.0.0", 67 | "eslint-plugin-ember": "^11.11.1", 68 | "eslint-plugin-n": "^16.2.0", 69 | "eslint-plugin-prettier": "^5.0.1", 70 | "eslint-plugin-qunit": "^8.0.1", 71 | "eyereasoner": "^11.1.0", 72 | "loader.js": "^4.7.0", 73 | "prettier": "^3.0.3", 74 | "qunit": "^2.20.0", 75 | "qunit-dom": "^2.0.0", 76 | "rdflib": "^2.2.20", 77 | "solid-storage-root": "^1.0.1", 78 | "stylelint": "^15.11.0", 79 | "stylelint-config-standard": "^34.0.0", 80 | "stylelint-prettier": "^4.0.2", 81 | "tracked-built-ins": "^3.3.0", 82 | "uuid": "^8.3.2", 83 | "webpack": "^5.89.0" 84 | }, 85 | "engines": { 86 | "node": ">= 18" 87 | }, 88 | "ember": { 89 | "edition": "octane" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /public/forkme_right_gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/public/forkme_right_gray.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /tests/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | setupApplicationTest as upstreamSetupApplicationTest, 3 | setupRenderingTest as upstreamSetupRenderingTest, 4 | setupTest as upstreamSetupTest, 5 | } from 'ember-qunit'; 6 | 7 | // This file exists to provide wrappers around ember-qunit's 8 | // test setup functions. This way, you can easily extend the setup that is 9 | // needed per test type. 10 | 11 | function setupApplicationTest(hooks, options) { 12 | upstreamSetupApplicationTest(hooks, options); 13 | 14 | // Additional setup for application tests can be done here. 15 | // 16 | // For example, if you need an authenticated session for each 17 | // application test, you could do: 18 | // 19 | // hooks.beforeEach(async function () { 20 | // await authenticateSession(); // ember-simple-auth 21 | // }); 22 | // 23 | // This is also a good place to call test setup functions coming 24 | // from other addons: 25 | // 26 | // setupIntl(hooks); // ember-intl 27 | // setupMirage(hooks); // ember-cli-mirage 28 | } 29 | 30 | function setupRenderingTest(hooks, options) { 31 | upstreamSetupRenderingTest(hooks, options); 32 | 33 | // Additional setup for rendering tests can be done here. 34 | } 35 | 36 | function setupTest(hooks, options) { 37 | upstreamSetupTest(hooks, options); 38 | 39 | // Additional setup for unit tests can be done here. 40 | } 41 | 42 | export { setupApplicationTest, setupRenderingTest, setupTest }; 43 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FormGenerator Tests 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | {{content-for "test-head"}} 11 | 12 | 13 | 14 | 15 | 16 | {{content-for "head-footer"}} 17 | {{content-for "test-head-footer"}} 18 | 19 | 20 | {{content-for "body"}} 21 | {{content-for "test-body"}} 22 | 23 |
24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {{content-for "body-footer"}} 37 | {{content-for "test-body-footer"}} 38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/tests/integration/.gitkeep -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from 'form-generator/app'; 2 | import config from 'form-generator/config/environment'; 3 | import * as QUnit from 'qunit'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import { setup } from 'qunit-dom'; 6 | import { start } from 'ember-qunit'; 7 | 8 | setApplication(Application.create(config.APP)); 9 | 10 | setup(QUnit.assert); 11 | 12 | start(); 13 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolidLabResearch/FormGenerator/d83b7d1b9c5fa9d288a25ecc6cec9aaf3d310c8d/tests/unit/.gitkeep -------------------------------------------------------------------------------- /tests/unit/controllers/index-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'form-generator/tests/helpers'; 3 | 4 | module('Unit | Controller | index', function (hooks) { 5 | setupTest(hooks); 6 | 7 | // TODO: Replace this with your real tests. 8 | test('it exists', function (assert) { 9 | let controller = this.owner.lookup('controller:index'); 10 | assert.ok(controller); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/unit/routes/index-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'form-generator/tests/helpers'; 3 | 4 | module('Unit | Route | index', function (hooks) { 5 | setupTest(hooks); 6 | 7 | test('it exists', function (assert) { 8 | let route = this.owner.lookup('route:index'); 9 | assert.ok(route); 10 | }); 11 | }); 12 | --------------------------------------------------------------------------------