Skip to content
Snippets Groups Projects
Commit ef7548b8 authored by Rich's avatar Rich
Browse files

basic Vue form presenting

parent d0e62131
Branches
Tags
No related merge requests found
node_modules
......@@ -13,6 +13,7 @@ class FormProcessor extends InlayType {
public static $defaultConfig = [
'formProcessor' => NULL,
'layout' => '',
'submitButtonText' => 'Submit',
];
......@@ -50,6 +51,70 @@ class FormProcessor extends InlayType {
'submitButtonText' => $this->config['submitButtonText'],
];
$fp = civicrm_api3('FormProcessorInstance', 'get', ['sequential' => 1, 'name' => $this->config['formProcessor']])['values'][0] ?? NULL;
if (!$fp) {
// aaaagh!
throw new RuntimeException("Cannot load form processor '{$this->config['formProcessor']}'");
}
// Parse the layout.
$inputs = [];
foreach ($fp['inputs'] as $_) {
$inputs[$_['name']] = $_;
}
$init['layout'] = [];
// First there's on item on the stack which is the top level thing.
$stack = [&$init['layout']];
$ptr = 0;
$depth = 0;
foreach (preg_split('/[\r\n]+/', $this->config['layout']) as $line) {
if (!trim($line)) {
continue;
}
$m = [];
if (!preg_match('/^(\s*)(\.?)([a-zA-Z_-][a-zA-Z0-9_-]*)$/', $line, $m)) {
// Broken! @todo flag this somehow. Possibly abort the rebuild.
continue;
}
$lineDepth = strlen($m[1]);
$isGroup = $m[2] === '.';
$name = $m[3];
while ($lineDepth < $depth) {
array_pop($stack);
$ptr--;
$depth--;
}
if ($isGroup) {
// new group.
$item = ['tag' => 'FieldGroup', 'class' => $name, 'content' => []];
// Add this item to the current collection.
$stack[$ptr][] = &$item;
// Add an item to the stck itself, do our new collection is the item's fields.
$stack[] = &$item['content'];
$ptr++;
$depth++;
}
else {
// field.
if (!isset($inputs[$name])) {
continue;
}
$item = ['tag' => 'IfpField', 'class' => $name, 'content' => $name];
$stack[$ptr][] = $item;
}
unset($item);
}
// Export the field definitions, keyed by name.
$init['fieldDefs'] = [];
foreach ($fp['inputs'] as $_) {
$init['fieldDefs'][$_['name']] = $_;
}
return $init;
}
......
/* Add any CSS rules for Angular module "inlayfp" */
.layout-demo div {
margin: 0.5rem 0;
padding: 0.5rem;
color: #444;
}
.layout-demo .group,
.layout-demo .field {
border: solid 1px rgba(0,0,0,0.2);
margin: 0.5rem 0;
padding: 0.5rem;
}
.layout-demo div.group>.items {
display: flex;
margin: 0 -0.5rem;
}
.layout-demo .items>div {
flex: 1 1 auto;
margin: 0 0.5rem;
}
......@@ -33,6 +33,30 @@
</select>
</div>
<div crm-ui-field="{name: 'inlayForm.layout', title: ts('Layout'), help: hs('layout')}" ng-if="fp">
<textarea
rows="3"
cols="30"
crm-ui-id="inlayForm.layout"
name="layout"
ng-model="inlay.config.layout"
class="crm-form-text"
placeholder="{{ts('Our lovely form processor form')}}"
ng-change="checkLayout()"
></textarea>
<p>Fields:
<span ng-repeat="f in fp.inputs"
ng-style="{color: !!layoutParsed.fieldsUsed[f.name] ? '#080' : '#800'}"
>{{f.name}} <span ng-if="f.is_required == 1">(required)</span> &nbsp; </span>
</p>
<div class="layout-demo" ng-bind-html="layoutParsed.html"></div>
<ul ng-if="layoutParsed.errors.length" class="error">
<li ng-repeat="e in layoutParsed.errors">{{e}}</li>
</ul>
</div>
<div>
<button ng-click="save()">{{ts('Save')}}</button>
</div>
......
console.log("hello");
(function(angular, $, _) {
angular.module('inlayfp').config(function($routeProvider) {
......@@ -18,7 +17,7 @@ console.log("hello");
}
return crmApi4(params)
.then(r => {
return crmApi('FormProcessorInstance', 'get', {})
return crmApi('FormProcessorInstance', 'get', {sequential: 1})
.then(r2 => {
r.formProcessors = r2.values;
return r;
......@@ -54,6 +53,7 @@ console.log("hello");
}
const inlay = $scope.inlay;
$scope.formProcessors = various.formProcessors;
console.log(various.formProcessors);
$scope.save = function() {
return crmStatus(
......@@ -67,6 +67,74 @@ console.log("hello");
});
};
$scope.fp = null;
$scope.fpInputs = null;
$scope.setFP = function() {
$scope.fp = various.formProcessors.find(fp => fp.name === inlay.config.formProcessor);
$scope.fpInputs = {};
if ($scope.fp) {
// name-indexed lookup of inputs.
$scope.fp.inputs.forEach(i => { $scope.fpInputs[i.name] = i; });
}
};
$scope.setFP();
/**
* Parse a layout like
* .group-class
* field1
* field2
* field3
*
*/
$scope.checkLayout = function checkLayout() {
console.log("checkLayout running", inlay.config.layout);
var html = '<div>', stack = ['</div>'], indent= '', errors= [], fieldsUsed = {};
var items = inlay.config.layout.split(/[\r\n]+/);
items.forEach(i => {
var m = i.match(/^( *)(\.?[a-zA-Z_-][a-zA-Z0-9_-]*)$/);
if (!m) {
errors.push(`The line '${i}' is invalid.`);
return;
}
// @todo get field details.
if (m[1].length < indent.length) {
// decreased - close a group.
html += stack.splice(0, indent.length - m[1].length).join('');
}
else if (m[1].length > indent.length) {
// increased indentation without starting a group.
errors.push(`The line '${i}' is invalid - too much indentation. Expected ${indent.length} got ${m[1].length}`);
return;
}
if (m[2].substr(0, 1) === '.') {
// Start a new group.
indent += ' ';
html += `<div class="group group--${m[2].substr(1)}">${m[2]}<div class="items">`;
stack.unshift('</div></div>');
}
else {
// Is a field.
if (m[2] in $scope.fpInputs) {
html += `<div class="field">Field: ${m[2]}</div>`;
fieldsUsed[m[2]] = 1;
}
else {
errors.push(`Field ${m[2]} is not defined as an input in the selected Form Processor.`);
}
}
});
html += stack.join('');
$scope.layoutParsed = {
html, errors, fieldsUsed
};
}
$scope.checkLayout();
});
})(angular, CRM.$, CRM._);
This diff is collapsed.
{
"/dist/inlayfp.js": "/dist/inlayfp.js"
}
This diff is collapsed.
{
"name": "src",
"version": "1.0.0",
"description": "",
"main": "inlayfp.js",
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "npm run development -- --watch",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"cross-env": "^7.0.2",
"laravel-mix": "^5.0.7",
"node-sass": "^4.14.1",
"sass-loader": "^10.0.4",
"vue-template-compiler": "^2.6.12"
},
"dependencies": {
"vue": "^2.6.12"
}
}
<template>
<div :class="groupClass">
<div v-for="(item, idx) in fields"
:is="item.tag"
:key="idx"
:content="item.content"
:groupClass="(item.tag === 'FieldGroup') ? 'ifp-group' : 'ifp-field'"
></div>
</div>
</div>
</template>
<script>
import IfpField from './IfpField.vue';
export default {
name: 'FieldGroup',
props: ['content', 'groupClass'],
components: { IfpField},
data() {
return {
fields: this.content,
}
}
}
</script>
<style lang="scss">
.ifp-group {
margin-left: -1rem;
margin-right: -1rem;
display: flex;
&>* {
padding:0 1rem;
flex: 1 1 auto;
}
}
</style>
<template>
<div :class="content">
<label >{{label}}</label>
<input
v-if="isInputType"
:name="def.name"
:type="inputType"
:ref="def.name"
:required="def.is_required == 1"
v-model="$root.values[def.name]"
/>
<textarea
v-if="isTextareaType"
rows=4
cols=40
v-model="$root.values[def.name]"
:required="def.is_required == 1"
:name="def.name"
:ref="def.name"
/>
</div>
</template>
<script>
import IfpField from './IfpField.vue';
export default {
props: ['content'],
computed: {
def() {
return this.$root.inlay.initData.fieldDefs[this.content];
},
inputType() {
if (this.def.type.name === 'String') {
// Could be text or email.
if (this.def.validators.find(v => v.validator.name === 'email')) {
return 'email';
}
return 'text';
}
if (this.def.type.name === 'Text') {
return 'textarea';
}
},
isInputType() {
return (this.inputType === 'text' || this.inputType === 'email' );
},
isTextareaType() {
return (this.inputType === 'textarea');
},
label() {
return this.def.title;
}
},
created() {
this.$root.inlay.initData.fieldDefs[this.content].include = true;
}
}
</script>
<template>
<div style="overflow:hidden;">
<form action='#' @submit.prevent="submitForm">
<field-group
:content="inlay.initData.layout"
group-class=""
></field-group>
<div class="ifg-submit">
<button
@click="wantsToSubmit"
>{{inlay.initData.submitButtonText}}</button>
<inlay-progress ref="progress"></inlay-progress>
</div>
</form>
</div>
</template>
<script>
import FieldGroup from './FieldGroup.vue';
import InlayProgress from './InlayProgress.vue';
export default {
props: ['inlay'],
components: {FieldGroup, InlayProgress},
data() {
return {};
},
computed: {
},
methods: {
wantsToSubmit() {
// validate all fields.
},
submitForm() {
// Form is valid according to browser.
const d = {};
Object.keys(this.$root.values).forEach(fieldName => {
if (this.$root.inlay.initData.fieldDefs[fieldName].include) {
d[fieldName] = this.$root.values[fieldName];
}
});
console.log("would submit: ", d);
this.$refs.progress.startTimer(5, 20, 1);
}
}
}
</script>
<template>
<div class="ifg-progress"
:style="style"
></div>
</template>
<script>
export default {
data() {
return {}
},
props: {
color: {
default: '#46a'
}
},
methods: {
startTimer(expectedTime, percentDoneAtEndOfJob, reset) {
expectedTime = expectedTime * 1000;
if (reset) {
this.doneBefore=0;
this.percentDoneAtEndOfJob = percentDoneAtEndOfJob;
this.expectedTime= expectedTime;
this.percent= 0;
this.start= null;
this.running= false;
}
else {
// Adding a job.
this.doneBefore = progress.percent;
this.start = null;
this.expectedTime = expectedTime;
this.percentDoneAtEndOfJob = percentDoneAtEndOfJob;
}
if (!this.running) {
// Start animation.
this.running = true;
window.requestAnimationFrame(this.animateTimer.bind(this))
}
},
cancelTimer() {
this.start = null;
this.running = false;
},
animateTimer(t) {
if (!this.start) {
this.start = t;
}
const linear = Math.min(1, (t - this.start) / this.expectedTime);
const easeout = 1 - (1-linear) * (1-linear) * (1-linear);
this.percent = this.doneBefore + easeout * (this.percentDoneAtEndOfJob - this.doneBefore);
if (this.running && (linear < 1)) {
window.requestAnimationFrame(this.animateTimer.bind(this));
}
else {
this.running = false;
}
},
},
computed: {
style() {
return {
backgroundColor: (this.running ? this.color : 'transparent'),
width: this.percent + '%'
};
}
}
}
</script>
<style lang="scss">
.ifg-progress {
height: 2px;
}
</style>
<template>
<div class="ifg-progress"
:style="style"
></div>
</template>
<script>
export default {
data() {
return {}
},
props: {
color: {
default: '#46a'
}
},
methods: {
startTimer(expectedTime, percentDoneAtEndOfJob, reset) {
expectedTime = expectedTime * 1000;
if (reset) {
this.doneBefore=0;
this.percentDoneAtEndOfJob = percentDoneAtEndOfJob;
this.expectedTime= expectedTime;
this.percent= 0;
this.start= null;
this.running= false;
}
else {
// Adding a job.
this.doneBefore = progress.percent;
this.start = null;
this.expectedTime = expectedTime;
this.percentDoneAtEndOfJob = percentDoneAtEndOfJob;
}
if (!this.running) {
// Start animation.
this.running = true;
window.requestAnimationFrame(this.animateTimer.bind(this))
}
},
cancelTimer() {
this.start = null;
this.running = false;
},
animateTimer(t) {
if (!this.start) {
this.start = t;
}
const linear = Math.min(1, (t - this.start) / this.expectedTime);
const easeout = 1 - (1-linear) * (1-linear) * (1-linear);
this.percent = this.doneBefore + easeout * (this.percentDoneAtEndOfJob - this.doneBefore);
if (this.running && (linear < 1)) {
window.requestAnimationFrame(animateTimer);
}
else {
this.running = false;
}
},
},
computed: {
style() {
return {
backgroundColor: (this.running ? this.color : 'transparent'),
width: this.percent + '%'
};
}
}
}
</script>
<style lang="scss">
.ifg-progress {
height: 2px;
}
</style>
import Vue from 'vue';
import InlayFormProcessor from './InlayFormProcessor.vue';
(() => {
if (!window.inlayFPInit) {
// This is the first time this *type* of Inlay has been encountered.
// We need to define anything global here.
// Create the boot function.
window.inlayFPInit = inlay => {
const inlayFPNode = document.createElement('div');
inlay.script.insertAdjacentElement('afterend', inlayFPNode);
/* eslint no-unused-vars: 0 */
const app = new Vue({
el: inlayFPNode,
data() {
const values = {};
Object.keys(inlay.initData.fieldDefs).forEach(fieldName => {
values[fieldName] = '';
inlay.initData.fieldDefs[fieldName].include = false;
});
var d = {inlay, values};
return d;
},
render: h => h(InlayFormProcessor, {props: {inlay}})
});
};
}
})();
let mix = require('laravel-mix');
mix.js('src/inlayfp.js', 'dist/');
// Full API
// mix.js(src, output);
// mix.react(src, output); <-- Identical to mix.js(), but registers React Babel compilation.
// mix.preact(src, output); <-- Identical to mix.js(), but registers Preact compilation.
// mix.coffee(src, output); <-- Identical to mix.js(), but registers CoffeeScript compilation.
// mix.ts(src, output); <-- TypeScript support. Requires tsconfig.json to exist in the same folder as webpack.mix.js
// mix.extract(vendorLibs);
// mix.sass(src, output);
// mix.less(src, output);
// mix.stylus(src, output);
// mix.postCss(src, output, [require('postcss-some-plugin')()]);
// mix.browserSync('my-site.test');
// mix.combine(files, destination);
// mix.babel(files, destination); <-- Identical to mix.combine(), but also includes Babel compilation.
// mix.copy(from, to);
// mix.copyDirectory(fromDir, toDir);
// mix.minify(file);
// mix.sourceMaps(); // Enable sourcemaps
// mix.version(); // Enable versioning.
// mix.disableNotifications();
// mix.setPublicPath('path/to/public');
// mix.setResourceRoot('prefix/for/resource/locators');
// mix.autoload({}); <-- Will be passed to Webpack's ProvidePlugin.
// mix.webpackConfig({}); <-- Override webpack.config.js, without editing the file directly.
// mix.babelConfig({}); <-- Merge extra Babel configuration (plugins, etc.) with Mix's default.
// mix.then(function () {}) <-- Will be triggered each time Webpack finishes building.
// mix.when(condition, function (mix) {}) <-- Call function if condition is true.
// mix.override(function (webpackConfig) {}) <-- Will be triggered once the webpack config object has been fully generated by Mix.
// mix.dump(); <-- Dump the generated webpack config object to the console.
// mix.extend(name, handler) <-- Extend Mix's API with your own components.
// mix.options({
// extractVueStyles: false, // Extract .vue component styling to file, rather than inline.
// globalVueStyles: file, // Variables file to be imported in every component.
// processCssUrls: true, // Process/optimize relative stylesheet url()'s. Set to false, if you don't want them touched.
// purifyCss: false, // Remove unused CSS selectors.
// terser: {}, // Terser-specific options. https://github.com/webpack-contrib/terser-webpack-plugin#options
// postCss: [] // Post-CSS options: https://github.com/postcss/postcss/blob/master/docs/plugins.md
// });
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment