dynaform.component.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { Component, Input, Output, EventEmitter, TemplateRef, Optional, OnInit, OnChanges, ChangeDetectionStrategy } from '@angular/core';
  2. import { FormBuilder, FormControl, FormGroup, FormArray, FormGroupName, AbstractControl, ControlContainer } from '@angular/forms';
  3. import { SuperForm } from 'angular-super-validator';
  4. import { buildFormGroupFunctionFactory, buildFormControl } from './services/_formdata-utils';
  5. import { cloneDeep } from 'lodash/fp';
  6. export interface DynarowContext {
  7. control: AbstractControl;
  8. meta: StringMap<any>;
  9. }
  10. @Component({
  11. selector: 'app-dynaform',
  12. templateUrl: './dynaform.component.html',
  13. styleUrls: ['./dynaform.component.scss'],
  14. changeDetection: ChangeDetectionStrategy.OnPush // or ChangeDetectionStrategy.OnPush - should be more efficient. Experiment later.
  15. })
  16. export class DynaformComponent implements OnInit, OnChanges {
  17. /*
  18. * DynaformComponent: <app-dynaform>
  19. *
  20. * USAGE:
  21. *
  22. * Supply component with either a FormGroup or the name of a FormGroup,
  23. * the forms full metadata tree,
  24. * and, optionally, a TemplateRef used as a field row template (overrides the default template)
  25. *
  26. * e.g.
  27. * <app-dynaform [formGroup]="myForm" [meta]="myFormMetaData"></app-dynaform>
  28. * <app-dynaform formGroupName="myFormGroupName" [meta]="myFormMetaData"></app-dynaform>
  29. * <app-dynaform [formGroup]="myForm" [meta]="myFormMetaData" [template]="myFieldTemplate"></app-dynaform>
  30. *
  31. * If supplied with just a FormGroupName it will retieve the FormGroup from the injected ControlContainer
  32. *
  33. */
  34. @Input()
  35. formGroup?: FormGroup;
  36. @Input()
  37. formGroupName?: FormGroupName;
  38. @Input()
  39. set meta(data) {
  40. // console.log('Dynaform Set Meta');
  41. // this.formMetaData = this.formMetaData || data; // WHY? WHY? WHY? - leave in for now, just in case
  42. this.formMetaData = data;
  43. }
  44. @Input()
  45. template?: TemplateRef<any>;
  46. @Input()
  47. debug = false;
  48. @Output()
  49. call: EventEmitter<string> = new EventEmitter<string>();
  50. formMetaData: StringMap<any>; // TODO: Tighten up type
  51. controlNames: string[];
  52. dynaFormRows: string[];
  53. path: string[]; // path of current FormGroup - can be used to respond differently based on nesting level in template
  54. // Colours for CSS in console
  55. conRed = 'color: white; background-color: maroon; font-weight: bold;';
  56. conGreen = 'color: white; background-color: green; font-weight: bold;';
  57. conOlive = 'color: white; background-color: olive; font-weight: bold;';
  58. constructor(
  59. @Optional() private cc: ControlContainer
  60. ) {}
  61. ngOnInit() {
  62. // console.log('Dyanaform ngOnInit');
  63. }
  64. ngOnChanges() {
  65. // Triggered when inputs change
  66. console.log('%c *** DynaformChange *** ', this.conOlive);
  67. console.log(this.formMetaData);
  68. // Get the formGroup from the formGroupName if necessary
  69. if (!this.formGroup && this.formGroupName) {
  70. this.formGroup = this.cc.control as FormGroup; // Get theFormGroup from the injected ControlContainer
  71. }
  72. if (!this.formGroup) {
  73. throw new Error(`Dynaform Component initialised without [formGroup] or cant't find formGroup from formGroupName ${this.formGroupName}`);
  74. }
  75. if (typeof this.formMetaData !== 'object') {
  76. throw new Error('Dynaform: [meta] should be an object');
  77. }
  78. this.controlNames = Object.keys(this.formGroup.controls);
  79. this.path = this.getFormGroupPath();
  80. if (this.debug && this.path.length < 2) {
  81. this.displayDebuggingInConsole();
  82. }
  83. // If we're given a formGroupName or nested FormGroup, and the form's full (or partial but fuller) metadata tree,
  84. // drill down to find *this* FormGroup's metadata
  85. const path = [...this.path]; // Clone to avoid mutating this.path
  86. const metaDataKeysExpected = this.controlNames.join(',');
  87. while (path.length && metaDataKeysExpected !== this.getContolKeysCSVFromMetadata(this.formMetaData)) {
  88. const branch = path.pop();
  89. this.formMetaData = this.formMetaData[branch].meta;
  90. }
  91. this.dynaFormRows = Object.keys(this.formMetaData);
  92. // Check we've got a "FormGroup <---> MetaData" match
  93. const metaDataKeysFound = this.getContolKeysCSVFromMetadata(this.formMetaData);
  94. if (metaDataKeysFound !== metaDataKeysExpected) {
  95. throw new Error(`
  96. Dynaform can't match FormGroup's controls with metadata
  97. Expected ${metaDataKeysExpected}
  98. Got ${metaDataKeysFound}`
  99. );
  100. }
  101. }
  102. getFormGroupPath(): string[] {
  103. // Get the full path of the FormGroup (used to match and validate metadata)
  104. let path = [];
  105. if (!this.formGroup && this.formGroupName) {
  106. // formGroupName supplied, so get the full path from the ControlContainer
  107. path = this.cc.path.reverse();
  108. } else {
  109. // formGroup supplied directly, so reconstruct current FormGroup's full path
  110. let fg = this.formGroup;
  111. while (fg.parent) {
  112. // Find the identity of 'fg' in the parent FormGroup's controls
  113. const fgIdentity = Object.entries(fg.parent.controls).find(([key, candidate]) => candidate === fg);
  114. path.push(fgIdentity[0]);
  115. fg = fg.parent as FormGroup;
  116. }
  117. }
  118. return path;
  119. }
  120. getEmbeddedDynaformClasses(meta: StringMap<any>): StringMap<boolean> {
  121. let ngClassObj = {};
  122. if (Array.isArray(meta.class)) {
  123. ngClassObj = (meta.class as string[]).reduce((acc, className) => (acc[className] = true, acc), ngClassObj);
  124. } else if (typeof meta.class === 'string' && meta.class) {
  125. ngClassObj[meta.class] = true;
  126. }
  127. return ngClassObj;
  128. }
  129. isField(meta: StringMap<any>): boolean {
  130. return !meta.type.includes('Container') && meta.type !== 'RepeatingField';
  131. }
  132. getTemplateContext(controlName: string): DynarowContext {
  133. return {
  134. control: this.formGroup.get(controlName),
  135. meta: this.formMetaData[controlName]
  136. };
  137. }
  138. getRepeatingTemplateContext(name: string, index: number): DynarowContext {
  139. const rcFormArray = this.formGroup.get(name) as FormArray;
  140. const result = {
  141. control: rcFormArray.at(index),
  142. meta: this.formMetaData[name]['meta'][index]
  143. };
  144. return result;
  145. }
  146. getRowClass(control: FormControl, meta: StringMap<any>): string {
  147. const fieldTypeClass = meta.type ? meta.type.toLowerCase().replace('component', '') : '';
  148. const fieldClass = Array.isArray(meta.class) ? meta.class.join(' ') : meta.class;
  149. const containerClass = fieldClass ? (meta.type === 'Container' ? ` ${fieldClass}` : ` row-${fieldClass}`) : '';
  150. const errorClass = control && control.touched && control.invalid ? ' dyna-error' : '';
  151. return `row-${fieldTypeClass}${containerClass}${errorClass}`;
  152. }
  153. getRepeatingContainerLabel(repeatingContainerName: string, index: number): string {
  154. // Get the label for a repeating container,
  155. // used on buttons to switch between containers (when only one is shown at a time i.e display = 'SINGLE')
  156. const rcMeta = this.formMetaData[repeatingContainerName];
  157. const primaryField = rcMeta.primaryField;
  158. if (primaryField) {
  159. // The primaryField has been specified, so return its value
  160. const rcFormArray = this.formGroup.get(repeatingContainerName) as FormArray;
  161. const formGroup = rcFormArray.at(index);
  162. return formGroup.get(primaryField).value || '…';
  163. } else {
  164. // Otherwise return the 'button' of the container (in the array of containers)
  165. return rcMeta.meta[index].button;
  166. }
  167. }
  168. focusContainer(name: string, index: number): void {
  169. // Show a particular member of a Repeating Container Group (used when only one is shown at a time i.e display = 'SINGLE')
  170. const rcMeta = this.formMetaData[name];
  171. rcMeta.meta = rcMeta.meta.map( (container, i) => ({ ...container, focussed: i === index }) );
  172. }
  173. addAllowed(name: string): boolean {
  174. const meta = this.formMetaData[name];
  175. return typeof meta.maxRepeat === 'number' && meta.maxRepeat > meta.meta.length;
  176. }
  177. // Maybe move the AddRF and deleteRF funtions to _formdata-utils.ts ?
  178. addRepeatingFieldMember(name: string): void {
  179. // (1) Check that we can still add controls
  180. if (!this.addAllowed(name)) {
  181. return;
  182. }
  183. // (2) Add metadata for new container member
  184. const rfMeta = this.formMetaData[name];
  185. const fieldTemplate = cloneDeep(rfMeta.__template);
  186. const i = this.formMetaData[name].meta.length;
  187. fieldTemplate.name = `group${i+1}`; // CHECK
  188. rfMeta.meta.push(fieldTemplate);
  189. // (3) Add FormControl to FormArray
  190. const newFormControl = buildFormControl(fieldTemplate);
  191. (this.formGroup.get(name) as FormArray).push(newFormControl);
  192. }
  193. // Maybe move the AddRC and deleteRC funtions to _formdata-utils.ts ?
  194. addRepeatingContainerMember(name: string): void {
  195. // (1) Check that we can still add controls
  196. if (!this.addAllowed(name)) {
  197. return;
  198. }
  199. // (2) Add metadata for new container member
  200. const rcMeta = this.formMetaData[name];
  201. const containerTemplate = cloneDeep(rcMeta.__template);
  202. const i = this.formMetaData[name].meta.length;
  203. containerTemplate.name = `group${i+1}`;
  204. rcMeta.meta.push(containerTemplate);
  205. // (3) Add FormGroup for new container member
  206. const buildFormGroup = buildFormGroupFunctionFactory(new FormBuilder());
  207. const newFormGroup = buildFormGroup(containerTemplate.meta);
  208. (this.formGroup.get(name) as FormArray).push(newFormGroup);
  209. // (4) Focus new container member if display = 'SINGLE' (i.e. we're only showing one at once)
  210. if (rcMeta.display === 'SINGLE') {
  211. this.focusContainer(name, i);
  212. }
  213. }
  214. deleteAllowed(name: string): boolean {
  215. const meta = this.formMetaData[name];
  216. return typeof meta.minRepeat === 'number' && meta.minRepeat < meta.meta.length;
  217. }
  218. // Maybe move the AddRC and deleteRC funtions to _formdata-utils.ts ?
  219. deleteRepeatingMember(name: string, index: number): void {
  220. // (1) Check that we can still delete controls
  221. if (!this.deleteAllowed(name)) {
  222. return;
  223. }
  224. // (2) Delete from the metadata, and rename the groups
  225. const rcMeta = this.formMetaData[name];
  226. const metaArr = rcMeta.meta;
  227. const newMetaArr = [ ...metaArr.slice(0, index), ...metaArr.slice(index + 1) ]
  228. .map((m, i) => { m.name = `group${i+1}`; return m; });
  229. rcMeta.meta = newMetaArr;
  230. // (3) Delete the corresponding FormGroup from the FormArray
  231. (this.formGroup.get(name) as FormArray).removeAt(index);
  232. // (4) Focus the last if display = 'SINGLE' (i.e. we're only showing one at once)
  233. if (rcMeta.display === 'SINGLE') {
  234. this.focusContainer(name, newMetaArr.length - 1);
  235. }
  236. }
  237. getValidationFailureMessage(control: FormControl, meta: StringMap<any>) {
  238. if (control.errors) {
  239. const errKeys = Object.keys(control.errors);
  240. console.log(errKeys);
  241. return meta.valFailureMsgs[errKeys[0]];
  242. }
  243. }
  244. getValidationErrors() {
  245. if (!this.formGroup.valid) {
  246. const errorsFlat = SuperForm.getAllErrorsFlat(this.formGroup);
  247. return errorsFlat;
  248. }
  249. return false;
  250. }
  251. handleCallback(fnId: string) {
  252. this.call.emit(fnId);
  253. }
  254. private getContolKeysCSVFromMetadata(metadata: StringMap<any>): string {
  255. // Return CSV of control keys in current nesting-level's metadata,
  256. // excluding metadata points that don't create FormControls, FromGroups or FormArrays
  257. // (identified by their 'noFormControl' flag)
  258. // e.g. ButtonGroups, HTMLChunks, etc.
  259. return Object.entries(metadata)
  260. .filter(([key, val]) => !(val as StringMap<any>).noFormControls)
  261. .reduce((acc, [key]) => [...acc, key], [])
  262. .join(',');
  263. }
  264. private displayDebuggingInConsole(): void {
  265. if (this.debug) {
  266. console.log('%c *** MetaData *** ', this.conGreen);
  267. console.dir(this.formMetaData);
  268. console.log('%c *** FormGroup *** ', this.conGreen);
  269. console.dir(this.formGroup);
  270. }
  271. }
  272. }