active_element 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -2
  3. data/.strong_versions.yml +1 -0
  4. data/Gemfile +4 -0
  5. data/Gemfile.lock +108 -75
  6. data/Makefile +10 -0
  7. data/active_element.gemspec +1 -1
  8. data/app/assets/javascripts/active_element/application.js +1 -0
  9. data/app/assets/javascripts/active_element/form.js +16 -32
  10. data/app/assets/javascripts/active_element/json_field.js +391 -135
  11. data/app/assets/javascripts/active_element/setup.js +13 -8
  12. data/app/assets/javascripts/active_element/text_search_field.js +27 -28
  13. data/app/assets/javascripts/active_element/theme.js +1 -1
  14. data/app/assets/javascripts/active_element/timezones.js +6 -0
  15. data/app/assets/stylesheets/active_element/_dark.scss +86 -0
  16. data/app/assets/stylesheets/active_element/_variables.scss +2 -1
  17. data/app/assets/stylesheets/active_element/application.scss +166 -33
  18. data/app/controllers/active_element/application_controller.rb +5 -0
  19. data/app/controllers/concerns/active_element/default_controller_actions.rb +38 -0
  20. data/app/views/active_element/_user.html.erb +20 -0
  21. data/app/views/active_element/components/fields/_json.html.erb +24 -0
  22. data/app/views/active_element/components/form/_check_box.html.erb +1 -0
  23. data/app/views/active_element/components/form/_check_boxes.html.erb +1 -1
  24. data/app/views/active_element/components/form/_datetime_range_field.html.erb +14 -0
  25. data/app/views/active_element/components/form/_field.html.erb +10 -7
  26. data/app/views/active_element/components/form/_generic_field.html.erb +1 -0
  27. data/app/views/active_element/components/form/_json.html.erb +10 -2
  28. data/app/views/active_element/components/form/_label.html.erb +12 -1
  29. data/app/views/active_element/components/form/_select.html.erb +4 -1
  30. data/app/views/active_element/components/form/_summary.html.erb +11 -1
  31. data/app/views/active_element/components/form/_templates.html.erb +37 -22
  32. data/app/views/active_element/components/form/_text_area.html.erb +2 -1
  33. data/app/views/active_element/components/form/_text_search.html.erb +7 -3
  34. data/app/views/active_element/components/form.html.erb +20 -17
  35. data/app/views/active_element/components/json.html.erb +1 -0
  36. data/app/views/active_element/components/navbar.html.erb +26 -0
  37. data/app/views/active_element/components/table/_collection_row.html.erb +2 -1
  38. data/app/views/active_element/components/table/_field.html.erb +8 -0
  39. data/app/views/active_element/components/table/collection.html.erb +1 -1
  40. data/app/views/active_element/components/table/item.html.erb +5 -4
  41. data/app/views/active_element/default_views/edit.html.erb +5 -0
  42. data/app/views/active_element/default_views/index.html.erb +15 -0
  43. data/app/views/active_element/default_views/new.html.erb +4 -0
  44. data/app/views/active_element/default_views/show.html.erb +7 -0
  45. data/app/views/active_element/navbar/_menu.html.erb +1 -30
  46. data/app/views/active_element/theme/_select.html.erb +1 -1
  47. data/app/views/layouts/active_element.html.erb +16 -1
  48. data/config/brakeman.ignore +48 -0
  49. data/example_app/.gitattributes +7 -0
  50. data/example_app/.gitignore +35 -0
  51. data/example_app/.ruby-version +1 -0
  52. data/example_app/Gemfile +34 -0
  53. data/example_app/Gemfile.lock +296 -0
  54. data/example_app/README.md +24 -0
  55. data/example_app/Rakefile +6 -0
  56. data/example_app/app/assets/config/manifest.js +4 -0
  57. data/example_app/app/assets/images/.keep +0 -0
  58. data/example_app/app/assets/stylesheets/application.css +15 -0
  59. data/example_app/app/channels/application_cable/channel.rb +4 -0
  60. data/example_app/app/channels/application_cable/connection.rb +4 -0
  61. data/example_app/app/controllers/application_controller.rb +12 -0
  62. data/example_app/app/controllers/concerns/.keep +0 -0
  63. data/example_app/app/controllers/pets_controller.rb +6 -0
  64. data/example_app/app/controllers/users_controller.rb +6 -0
  65. data/example_app/app/helpers/application_helper.rb +2 -0
  66. data/example_app/app/javascript/application.js +3 -0
  67. data/example_app/app/javascript/controllers/application.js +9 -0
  68. data/example_app/app/javascript/controllers/hello_controller.js +7 -0
  69. data/example_app/app/javascript/controllers/index.js +11 -0
  70. data/example_app/app/jobs/application_job.rb +7 -0
  71. data/example_app/app/mailers/application_mailer.rb +4 -0
  72. data/example_app/app/models/application_record.rb +3 -0
  73. data/example_app/app/models/concerns/.keep +0 -0
  74. data/example_app/app/models/pet.rb +3 -0
  75. data/example_app/app/models/user.rb +8 -0
  76. data/example_app/app/views/layouts/application.html.erb +16 -0
  77. data/example_app/app/views/layouts/mailer.html.erb +13 -0
  78. data/example_app/app/views/layouts/mailer.text.erb +1 -0
  79. data/example_app/app/views/pets/index.html.erb +3 -0
  80. data/example_app/app/views/users/show.html.erb +3 -0
  81. data/example_app/bin/bundle +109 -0
  82. data/example_app/bin/importmap +4 -0
  83. data/example_app/bin/rails +4 -0
  84. data/example_app/bin/rake +4 -0
  85. data/example_app/bin/setup +33 -0
  86. data/example_app/config/application.rb +22 -0
  87. data/example_app/config/boot.rb +4 -0
  88. data/example_app/config/cable.yml +10 -0
  89. data/example_app/config/credentials.yml.enc +1 -0
  90. data/example_app/config/database.yml +25 -0
  91. data/example_app/config/environment.rb +5 -0
  92. data/example_app/config/environments/development.rb +70 -0
  93. data/example_app/config/environments/production.rb +93 -0
  94. data/example_app/config/environments/test.rb +60 -0
  95. data/example_app/config/importmap.rb +7 -0
  96. data/example_app/config/initializers/assets.rb +12 -0
  97. data/example_app/config/initializers/content_security_policy.rb +25 -0
  98. data/example_app/config/initializers/devise.rb +16 -0
  99. data/example_app/config/initializers/filter_parameter_logging.rb +8 -0
  100. data/example_app/config/initializers/inflections.rb +16 -0
  101. data/example_app/config/initializers/permissions_policy.rb +11 -0
  102. data/example_app/config/locales/devise.en.yml +65 -0
  103. data/example_app/config/locales/en.yml +33 -0
  104. data/example_app/config/puma.rb +43 -0
  105. data/example_app/config/routes.rb +8 -0
  106. data/example_app/config/storage.yml +34 -0
  107. data/example_app/config.ru +6 -0
  108. data/example_app/db/migrate/20230616210539_create_pet.rb +12 -0
  109. data/example_app/db/migrate/20230616211328_devise_create_users.rb +46 -0
  110. data/example_app/db/schema.rb +37 -0
  111. data/example_app/db/seeds.rb +33 -0
  112. data/example_app/lib/assets/.keep +0 -0
  113. data/example_app/lib/tasks/.keep +0 -0
  114. data/example_app/log/.keep +0 -0
  115. data/example_app/public/404.html +67 -0
  116. data/example_app/public/422.html +67 -0
  117. data/example_app/public/500.html +66 -0
  118. data/example_app/public/apple-touch-icon-precomposed.png +0 -0
  119. data/example_app/public/apple-touch-icon.png +0 -0
  120. data/example_app/public/favicon.ico +0 -0
  121. data/example_app/public/robots.txt +1 -0
  122. data/example_app/storage/.keep +0 -0
  123. data/example_app/test/application_system_test_case.rb +5 -0
  124. data/example_app/test/channels/application_cable/connection_test.rb +11 -0
  125. data/example_app/test/controllers/.keep +0 -0
  126. data/example_app/test/fixtures/files/.keep +0 -0
  127. data/example_app/test/fixtures/users.yml +11 -0
  128. data/example_app/test/helpers/.keep +0 -0
  129. data/example_app/test/integration/.keep +0 -0
  130. data/example_app/test/mailers/.keep +0 -0
  131. data/example_app/test/models/.keep +0 -0
  132. data/example_app/test/models/user_test.rb +7 -0
  133. data/example_app/test/system/.keep +0 -0
  134. data/example_app/test/test_helper.rb +13 -0
  135. data/example_app/tmp/.keep +0 -0
  136. data/example_app/tmp/pids/.keep +0 -0
  137. data/example_app/tmp/storage/.keep +0 -0
  138. data/example_app/vendor/.keep +0 -0
  139. data/example_app/vendor/javascript/.keep +0 -0
  140. data/lib/active_element/component.rb +9 -2
  141. data/lib/active_element/components/collection_table.rb +9 -2
  142. data/lib/active_element/components/email_fields.rb +14 -0
  143. data/lib/active_element/components/form.rb +48 -17
  144. data/lib/active_element/components/navbar.rb +64 -0
  145. data/lib/active_element/components/phone_fields.rb +14 -0
  146. data/lib/active_element/components/text_search/authorization.rb +9 -6
  147. data/lib/active_element/components/text_search/component.rb +4 -2
  148. data/lib/active_element/components/text_search.rb +4 -0
  149. data/lib/active_element/components/util/association_mapping.rb +74 -19
  150. data/lib/active_element/components/util/display_value_mapping.rb +13 -4
  151. data/lib/active_element/components/util/form_field_mapping.rb +127 -10
  152. data/lib/active_element/components/util/form_value_mapping.rb +3 -3
  153. data/lib/active_element/components/util/i18n.rb +1 -1
  154. data/lib/active_element/components/util/record_mapping.rb +43 -11
  155. data/lib/active_element/components/util/record_path.rb +21 -4
  156. data/lib/active_element/components/util.rb +12 -5
  157. data/lib/active_element/components.rb +3 -0
  158. data/lib/active_element/controller_action.rb +8 -2
  159. data/lib/active_element/controller_interface.rb +47 -5
  160. data/lib/active_element/default_controller.rb +93 -0
  161. data/lib/active_element/default_record_params.rb +62 -0
  162. data/lib/active_element/default_text_search.rb +110 -0
  163. data/lib/active_element/json_field_schema.rb +59 -0
  164. data/lib/active_element/pre_render_processors/json.rb +98 -0
  165. data/lib/active_element/pre_render_processors.rb +11 -0
  166. data/lib/active_element/route.rb +12 -0
  167. data/lib/active_element/routes.rb +2 -1
  168. data/lib/active_element/version.rb +1 -1
  169. data/lib/active_element.rb +14 -32
  170. data/lib/tasks/active_element.rake +12 -1
  171. data/rspec-documentation/_head.html.erb +34 -0
  172. data/rspec-documentation/pages/000-Introduction.md +18 -0
  173. data/rspec-documentation/pages/005-Setup.md +75 -0
  174. data/rspec-documentation/pages/010-Components/Form Fields/Check Boxes.md +1 -0
  175. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Controller Params.md +97 -0
  176. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Schema.md +283 -0
  177. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Types.md +36 -0
  178. data/rspec-documentation/pages/010-Components/Form Fields/JSON.md +70 -0
  179. data/rspec-documentation/pages/010-Components/Form Fields/Text Search.md +133 -0
  180. data/rspec-documentation/pages/010-Components/Form Fields.md +46 -0
  181. data/rspec-documentation/pages/010-Components/Forms.md +44 -0
  182. data/rspec-documentation/pages/010-Components/JSON Data.md +23 -0
  183. data/rspec-documentation/pages/010-Components/Navbar.md +56 -0
  184. data/rspec-documentation/pages/010-Components/Page Section Title.md +13 -0
  185. data/rspec-documentation/pages/010-Components/Page Subtitle.md +11 -0
  186. data/rspec-documentation/pages/010-Components/Page Title.md +11 -0
  187. data/rspec-documentation/pages/010-Components/Tables/Collection Table.md +29 -0
  188. data/rspec-documentation/pages/010-Components/Tables/Item Table.md +18 -0
  189. data/rspec-documentation/pages/010-Components/Tables/Options.md +19 -0
  190. data/rspec-documentation/pages/010-Components/Tables.md +29 -0
  191. data/rspec-documentation/pages/010-Components.md +15 -0
  192. data/rspec-documentation/pages/020-Access Control/010-Authentication.md +20 -0
  193. data/rspec-documentation/pages/020-Access Control/020-Authorization/Environments.md +9 -0
  194. data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions/Custom Routes.md +41 -0
  195. data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions.md +58 -0
  196. data/rspec-documentation/pages/020-Access Control/020-Authorization/Setup.md +27 -0
  197. data/rspec-documentation/pages/020-Access Control/020-Authorization.md +11 -0
  198. data/rspec-documentation/pages/020-Access Control.md +31 -0
  199. data/rspec-documentation/pages/040-Decorators/Inline Decorators.md +24 -0
  200. data/rspec-documentation/pages/040-Decorators/View Decorators.md +55 -0
  201. data/rspec-documentation/pages/040-Decorators.md +12 -0
  202. data/rspec-documentation/pages/300-Alternatives.md +21 -0
  203. data/rspec-documentation/pages/900-License.md +11 -0
  204. data/rspec-documentation/spec_helper.rb +53 -16
  205. data/rspec-documentation/support.rb +84 -0
  206. metadata +155 -14
  207. data/rspec-documentation/pages/Components/Forms.md +0 -1
  208. data/rspec-documentation/pages/Components/Tables.md +0 -47
  209. data/rspec-documentation/pages/Components.md +0 -1
  210. data/rspec-documentation/pages/Decorators/Inline Decorators.md +0 -1
  211. data/rspec-documentation/pages/Decorators/View Decorators.md +0 -1
  212. data/rspec-documentation/pages/Index.md +0 -3
  213. data/rspec-documentation/pages/Util/I18n.md +0 -1
  214. /data/rspec-documentation/pages/{Components → 010-Components}/Tabs.md +0 -0
@@ -2,6 +2,8 @@ ActiveElement.JsonField = (() => {
2
2
  const cloneElement = (id) => ActiveElement.cloneElement('json', id);
3
3
 
4
4
  const humanize = ({ string, singular = false }) => {
5
+ if (!string) return '';
6
+
5
7
  const humanized = string.split('_').map(item => item.charAt(0).toUpperCase() + item.substring(1)).join(' ');
6
8
 
7
9
  if (!singular) return humanized;
@@ -9,58 +11,187 @@ ActiveElement.JsonField = (() => {
9
11
  return humanized.replace(/s$/, ''); // FIXME: Expose translations from back-end to make this more useful.
10
12
  };
11
13
 
12
- const createStore = ({ data, store = { data: {}, paths: {} } }) => {
13
- const buildState = ({ data, store, path = [] }) => {
14
- const getPath = (key) => {
15
- return path.concat([key]);
16
- };
14
+ const isObject = (object) => object && typeof object === 'object';
17
15
 
18
- if (Array.isArray(data)) {
19
- return data.map((value, index) => buildState({ data: value, store, path: getPath(index) }));
20
- } else if (data && typeof(data) === 'object') {
21
- return Object.fromEntries(
22
- Object.entries(data).map(
23
- ([key, value]) => [key, buildState({ data: value, store, path: getPath(key) })]
24
- )
25
- );
16
+ const createStore = ({ data, schema, store = { data: {}, paths: {} } }) => {
17
+ const initializeState = ({ state, path, data, defaultValue }) => {
18
+ if (state) return state;
19
+
20
+ const id = ActiveElement.generateId();
21
+ store.paths[id] = path;
22
+ store.data[id] = data === undefined ? (defaultValue || null) : data;
23
+ return id;
24
+ };
25
+
26
+ const defaultState = ({ schema, path, defaultValue = null }) => {
27
+ if (schema.type === 'object') {
28
+ return Object.fromEntries(schema.shape.fields.map((field) => (
29
+ [field.name, defaultState({
30
+ schema: field, path: path.concat([field.name]),
31
+ defaultValue: defaultValue && defaultValue[field.name],
32
+ })]
33
+ )));
34
+ } else if (schema.type === 'array') {
35
+ return (Array.isArray(defaultValue) ? defaultValue : []).map((item, index) => (
36
+ defaultState({ schema: schema.shape, path: path.concat([index]), defaultValue: item })
37
+ ));
26
38
  } else {
27
- const id = crypto.randomUUID();
28
- store.data[id] = data;
39
+ const id = ActiveElement.generateId();
40
+ store.data[id] = defaultValue; // TODO: Default value from schema
29
41
  store.paths[id] = path;
30
42
  return id;
31
43
  }
32
44
  };
33
45
 
34
- const state = buildState({ data, store });
35
- const getValue = (key) => store.data[key];
36
- const setValue = (key, value) => store.data[key] = value;
46
+ store.state = defaultState({ schema, path: [], defaultValue: data });
47
+ console.log(store.state)
37
48
 
38
- return { state, store, getValue, setValue };
39
- };
49
+ const stateChangedCallbacks = [];
50
+ const stateChanged = (callback, state) => stateChangedCallbacks.push([callback, state]);
51
+ const notifyStateChanged = () => {
52
+ stateChangedCallbacks.forEach(([callback, state]) => callback({ getState, value: state && getValue(state) }));
53
+ };
54
+
55
+ const getValueAsDateTime = (value) => {
56
+ // TODO: Deal with timezone offset ?
57
+ const datetime = new Date(value);
40
58
 
41
- const getState = ({ store }) => {
42
- const data = {};
43
- const storeData = Object.entries(store.paths).forEach(([id, path]) => {
44
- let value = data;
45
- path.forEach((key, index) => {
46
- if (index === path.length - 1) {
47
- value[key] = store.data[id];
48
- } else if (typeof(key) === 'string') {
49
- value[key] = value[key] || {};
50
- value = value[key];
59
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local
60
+ const isoString = datetime.toISOString();
61
+ return isoString.substring(0, isoString.indexOf("T") + 6);
62
+ };
63
+
64
+ const getValueWithSchema = (value, schema) => {
65
+ switch (schema.type) {
66
+ case 'datetime':
67
+ return value ? getValueAsDateTime(value) : '';
68
+ default:
69
+ return value;
70
+ }
71
+ };
72
+
73
+ const getValue = (id, schema) => schema ? getValueWithSchema(store.data[id], schema) : store.data[id];
74
+ const deleteValue = (state) => {
75
+ const deleteObject = (id) => {
76
+ if (Array.isArray(id)) {
77
+ id.forEach((item) => deleteObject(item));
78
+ } else if (isObject(id)) {
79
+ Object.entries(id).forEach(([key, value]) => deleteObject(value));
51
80
  } else {
52
- value[key] = value[key] || [];
53
- value = value[key];
81
+ store.data[id] = undefined;
82
+ store.paths[id] = undefined;
83
+ }
84
+ };
85
+
86
+ deleteObject(state);
87
+ notifyStateChanged();
88
+ };
89
+
90
+ const setValue = (id, value) => {
91
+ const previousValue = getValue(id);
92
+
93
+ if (previousValue !== value) {
94
+ store.data[id] = value;
95
+ notifyStateChanged();
96
+ }
97
+ };
98
+
99
+ const appendValue = ({ path, schema }) => {
100
+ const getMaxIndex = (path) => {
101
+ const matchingPaths = Object.values(store.paths).filter((storePath) => {
102
+ const pathSlice = storePath?.slice(0, path.length);
103
+
104
+ return pathSlice && path.every((item, index) => item === pathSlice[index]);
105
+ });
106
+
107
+ if (!matchingPaths.length) return undefined;
108
+
109
+ return Math.max(...matchingPaths.map((matchingPath) => matchingPath[path.length]));
110
+ };
111
+
112
+ const id = ActiveElement.generateId();
113
+ const maxIndex = getMaxIndex(path);
114
+ const index = maxIndex === undefined ? 0 : maxIndex + 1;
115
+ const appendPath = path.concat([index]);
116
+ store.state[id] = defaultState({ schema: schema.shape, path: appendPath });
117
+ // store.paths[id] = appendPath; // XXX Needed ?
118
+ return { state: store.state[id], path: appendPath };
119
+ };
120
+
121
+ const getState = () => {
122
+ const getStructure = ({ path }) => {
123
+ return path.reduce((structure, key, index) => {
124
+ let structureField;
125
+
126
+ if (structure.type === 'object') {
127
+ structureField = structure.shape.fields.find((field) => field.name === key);
128
+ } else if (structure.type === 'array') {
129
+ structureField = structure.shape;
130
+ }
131
+
132
+ if (index === path.length - 1) {
133
+ return { array: [], object: {} }[structureField?.type || structure.shape.type];
134
+ } else {
135
+ return structureField;
136
+ }
137
+ }, schema);
138
+ };
139
+
140
+ const cleanEmpty = ((object) => {
141
+ if (Array.isArray(object)) {
142
+ const cleanedArray = Array.from(object.filter((item) => item !== undefined));
143
+ return cleanedArray.map((item) => cleanEmpty(item));
144
+ } else if (isObject(object)) {
145
+ const cleanedObject = Object.fromEntries(
146
+ Object.entries(object).filter(([key, value]) => value !== undefined)
147
+ );
148
+ return Object.fromEntries(
149
+ Object.entries(cleanedObject).map(([key, value]) => [key, cleanEmpty(value)])
150
+ );
151
+ } else {
152
+ return object;
54
153
  }
55
154
  });
56
155
 
57
- return value;
58
- },
59
- {});
156
+ const data = { array: [], object: {} }[schema.type];
157
+
158
+ Object.entries(store.paths).forEach(([id, path]) => {
159
+ let value = data;
60
160
 
61
- return data;
161
+ path?.forEach((key, index) => {
162
+ if (index === path.length - 1) {
163
+ if (store.data[id] !== undefined) value[key] = store.data[id];
164
+ } else {
165
+ value[key] = value[key] || getStructure({ path: path.slice(0, index + 1) });
166
+ value = value[key];
167
+ }
168
+ });
169
+ });
170
+
171
+ return cleanEmpty(data);
172
+ };
173
+
174
+ const handleEvent = (ev) => {
175
+ const id = ev.target.id;
176
+ setValue(id, getValueFromElement({ element: ev.target }));
177
+
178
+ return true;
179
+ };
180
+
181
+ const connectState = ({ element }) => {
182
+ element.addEventListener('keyup', (ev) => handleEvent(ev));
183
+ element.addEventListener('change', (ev) => handleEvent(ev));
184
+ notifyStateChanged();
185
+ };
186
+
187
+ return {
188
+ stateChanged,
189
+ connectState,
190
+ store: { state: store.state, getValue, setValue, deleteValue, initializeState, appendValue },
191
+ };
62
192
  };
63
193
 
194
+
64
195
  const getValueFromElement = ({ element }) => {
65
196
  if (element.type === 'checkbox') return element.checked;
66
197
 
@@ -79,41 +210,39 @@ ActiveElement.JsonField = (() => {
79
210
  return ActiveElement.jsonData[dataKey].schema;
80
211
  };
81
212
 
82
- const trackState = ({ element, schema, getValue }) => {
83
- element.addEventListener('change', (ev) => {
84
- const key = ev.target.id;
85
- const previousValue = getValue(key);
86
- const newValue = getValueFromElement({ element: ev.target });
87
-
88
- if (previousValue !== newValue) {
89
- // setValue(key, newValue);
90
- // TODO: Trigger callbacks
91
- }
92
- console.log(`Previous: ${previousValue}`);
93
- console.log(`Updated: ${newValue}`);
94
- return true;
95
- });
96
- };
213
+ const Component = ({ store, stateChanged, connectState, schema, element, fieldName }) => {
214
+ const ObjectField = ({ schema, state, path, omitLabel = false }) => {
215
+ const getPath = () => schema.name ? path.concat(schema.name) : path;
216
+ const currentPath = getPath();
97
217
 
98
- const Component = ({ getValue, schema, state, element }) => {
99
- const ObjectField = ({ schema, state, floating = true, omitLabel = false }) => {
100
218
  let element;
219
+
101
220
  switch (schema.type) {
102
221
  case 'boolean':
103
- return BooleanField({ state, omitLabel, schema });
222
+ return BooleanField({ state, omitLabel, schema, path: currentPath });
104
223
  case 'string':
105
- return StringField({ state, omitLabel, floating, schema });
106
- break;
224
+ return StringField({ state, omitLabel, schema, path: currentPath });
225
+ case 'date':
226
+ return DateField({ state, omitLabel, schema, path: currentPath });
227
+ case 'time':
228
+ return TimeField({ state, omitLabel, schema, path: currentPath });
229
+ case 'datetime':
230
+ return DateTimeField({ state, omitLabel, schema, path: currentPath });
231
+ case 'integer':
232
+ return IntegerField({ state, omitLabel, schema, path: currentPath });
233
+ case 'float':
234
+ return FloatField({ state, omitLabel, schema, path: currentPath });
235
+ case 'decimal':
236
+ return DecimalField({ state, omitLabel, schema, path: currentPath });
107
237
  case 'object':
108
238
  element = cloneElement('form-group-floating');
109
-
110
239
  (schema.shape.fields).forEach((field) => {
111
240
  element.append(
112
241
  ObjectField({
113
242
  name: field.name,
114
- floating: false,
115
243
  schema: field,
116
244
  state: state ? state[field.name] : null,
245
+ path: currentPath,
117
246
  })
118
247
  );
119
248
  });
@@ -121,20 +250,20 @@ ActiveElement.JsonField = (() => {
121
250
  return element;
122
251
  case 'array':
123
252
  element = cloneElement('form-group');
124
- const list = ArrayField({ schema, state });
125
- element.append(ExpandCollapseButton({ element }));
253
+ const list = ArrayField({ schema, state, path: currentPath });
254
+ if (schema.shape?.type === 'object') list.classList.add('array-of-objects');
255
+ element.append(AppendButton({ list, schema, state, path: currentPath }));
126
256
  element.append(Label({ title: schema.name }));
127
257
  element.append(list);
128
- element.append(AppendButton({ list, schema, state }));
129
258
  return element;
130
259
  }
131
260
  };
132
261
 
133
- const BooleanField = ({ omitLabel, schema, state }) => {
262
+ const BooleanField = ({ omitLabel, schema, state, path }) => {
134
263
  const checkbox = cloneElement('checkbox-field');
135
264
 
136
- checkbox.id = state;
137
- checkbox.checked = getValue(state);
265
+ checkbox.id = store.initializeState({ state, path, defaultValue: false });
266
+ checkbox.checked = store.getValue(state);
138
267
 
139
268
  if (omitLabel) return checkbox;
140
269
 
@@ -146,76 +275,167 @@ ActiveElement.JsonField = (() => {
146
275
  return element;
147
276
  };
148
277
 
149
- const ArrayField = ({ schema, state }) => {
278
+ const ArrayField = ({ schema, state, path: objectPath }) => {
150
279
  const element = cloneElement('list-group');
151
280
 
281
+ if (schema.focus) element.classList.add('focus');
282
+ element.classList.add('json-array-field');
283
+
152
284
  if (state) {
153
- state.forEach((value) => {
154
- const listItem = cloneElement('list-item');
155
- const objectField = ObjectField({
156
- omitLabel: true,
157
- schema: { ...schema, ...schema.shape },
158
- state: value,
159
- });
285
+ state.forEach((eachState, index) => {
286
+ const path = objectPath.concat([index]);
287
+ element.append(ArrayItem({ state: eachState, path, schema }));
288
+ });
289
+ }
160
290
 
161
- if (schema.shape.type == 'object') {
162
- const group = cloneElement('form-group');
163
- group.append(DeleteButton({ rootElement: listItem, template: 'delete-object-button' }));
164
- group.append(objectField);
165
- listItem.append(group);
166
- } else {
167
- listItem.append(objectField);
168
- listItem.append(DeleteButton({ rootElement: listItem }));
169
- }
291
+ return element;
292
+ };
170
293
 
171
- element.append(listItem);
172
- });
294
+ const ArrayItem = ({ state, path, schema, newItem = false }) => {
295
+ const element = cloneElement('list-item');
296
+ const wrapper = document.createElement('div');
297
+ const objectField = ObjectField({
298
+ path,
299
+ omitLabel: true,
300
+ schema: { ...schema.shape },
301
+ state: state
302
+ });
303
+
304
+ // TODO: Use same template etc. for all delete buttons, use presentation layer to
305
+ // handle UI differences.
306
+ if (schema.shape.type == 'object') {
307
+ const group = cloneElement('form-group');
308
+ const deleteObjectButton = DeleteButton(
309
+ { path, state, rootElement: element, template: 'delete-object-button' }
310
+ );
311
+
312
+ if (schema.focus) {
313
+ group.append(objectField);
314
+ wrapper.append(Focus({ state, schema, group, deleteObjectButton, newItem }));
315
+ } else {
316
+ // TODO: Tidy this up.
317
+ const deleteObjectButtonWrapper = document.createElement('div');
318
+ deleteObjectButtonWrapper.classList.add('delete-object-button-wrapper');
319
+ deleteObjectButtonWrapper.append(deleteObjectButton);
320
+ group.append(deleteObjectButtonWrapper);
321
+ group.append(objectField);
322
+ wrapper.append(group);
323
+ }
324
+ } else {
325
+ wrapper.append(objectField);
326
+ objectField.classList.add('deletable');
327
+ wrapper.append(DeleteButton({ path, state, rootElement: element }));
173
328
  }
174
329
 
330
+ element.append(wrapper);
331
+
332
+ return element;
333
+ };
334
+
335
+ const Focus = ({ state, schema, group, deleteObjectButton, newItem }) => {
336
+ const element = cloneElement('focus');
337
+ const valueElement = document.createElement('a');
338
+ const modal = cloneElement('modal');
339
+ const modalBody = modal.querySelector('[data-field-type="modal-body"]');
340
+ const modalHeader = modal.querySelector('.modal-header .modal-buttons');
341
+ const titleElement = modal.querySelector('[data-field-type="modal-title"]');
342
+ const bootstrapModal = new bootstrap.Modal(modal);
343
+
344
+ stateChanged(() => {
345
+ const pairs = schema.focus
346
+ .map((field) => [field, store.getValue(state[field])])
347
+ .filter(([_field, value]) => value)
348
+
349
+ const [field, value] = (pairs.length && pairs[0]) || [null, '[New item]'];
350
+ const isBoolean = typeof value === 'boolean';
351
+ const fieldTitle = isBoolean ? humanize({ string: field }) : value;
352
+ titleElement.innerText = fieldTitle;
353
+ valueElement.innerText = fieldTitle;
354
+ if (isBoolean) {
355
+ valueElement.classList.add('text-success');
356
+ valueElement.classList.remove('text-primary');
357
+ } else {
358
+ valueElement.classList.add('text-primary');
359
+ valueElement.classList.remove('text-success');
360
+ }
361
+ });
362
+
363
+ connectState({ element: modal });
364
+
365
+ valueElement.classList.add('focus-field-value');
366
+ valueElement.href = '#';
367
+ modalBody.append(group);
368
+ modalBody.classList.add('json-field');
369
+ titleElement.append(deleteObjectButton);
370
+ modalHeader.append(deleteObjectButton);
371
+ deleteObjectButton.addEventListener('click', () => bootstrapModal.hide());
372
+
373
+ valueElement.addEventListener('click', (ev) => {
374
+ ev.preventDefault();
375
+ bootstrapModal.toggle();
376
+ });
377
+ element.append(valueElement);
378
+ element.classList.add('focus', 'json-highlight');
379
+
380
+ if (newItem) bootstrapModal.toggle();
381
+
175
382
  return element;
176
383
  };
177
384
 
178
- const Label = ({ title, template }) => {
385
+ const Label = ({ title, template, labelFor }) => {
179
386
  const element = cloneElement(template || 'label');
180
387
 
181
388
  element.append(humanize({ string: title }));
182
389
 
390
+ if (labelFor) {
391
+ element.htmlFor = labelFor.id;
392
+ element.classList.add(`json-${labelFor.type}-field-label`);
393
+ }
394
+
183
395
  return element;
184
396
  }
185
397
 
398
+ const Option = ({ value, label, selected }) => {
399
+ const element = document.createElement('option');
400
+ element.value = value;
401
+ element.append(label || value);
402
+ element.selected = selected || false;
403
+ return element;
404
+ };
405
+
186
406
  const Select = ({ state, schema }) => {
187
407
  const element = cloneElement('select')
188
408
 
189
409
  element.id = state;
190
410
 
191
- schema.shape.options.forEach((option) => {
192
- const optionElement = document.createElement('option');
193
- optionElement.value = option;
194
- optionElement.append(option);
195
- optionElement.selected = option === getValue(state);
196
- element.append(optionElement);
411
+ element.append(Option({ value: '' }));
412
+
413
+ schema.options.forEach((value) => {
414
+ element.append(Option({ value, selected: value === store.getValue(state) }));
197
415
  });
198
416
 
199
417
  return element;
200
418
  };
201
419
 
202
- const TextField = ({ template, state, schema }) => {
420
+ const TextField = ({ template, state, schema, path }) => {
203
421
  const element = cloneElement(template || 'text-field');
204
422
 
205
- element.value = getValue(state) || '';
206
423
  element.id = state;
207
- element.placeholder = schema.shape?.placeholder || ' ';
424
+ element.value = store.getValue(state);
425
+ element.placeholder = schema.placeholder || schema.shape?.placeholder || ' ';
208
426
 
209
427
  return element;
210
428
  };
211
429
 
212
- const StringField = ({ omitLabel, floating, schema, state }) => {
430
+ const StringField = ({ omitLabel, schema, state, path }) => {
213
431
  let element;
214
432
 
215
- if (schema.shape?.options?.length) {
216
- element = Select({ state, schema });
433
+ state = store.initializeState({ state, path, data: '' });
434
+
435
+ if (schema.options?.length) {
436
+ element = Select({ state, schema, path });
217
437
  } else {
218
- element = TextField({ state, schema });
438
+ element = TextField({ state, schema, path });
219
439
  }
220
440
 
221
441
  if (omitLabel) return element;
@@ -223,36 +443,67 @@ ActiveElement.JsonField = (() => {
223
443
  const group = cloneElement('form-group-floating');
224
444
 
225
445
  group.append(element);
226
- group.append(Label({ title: schema.name }));
446
+ group.append(Label({ title: schema.name, labelFor: element }));
227
447
 
228
448
  return group;
229
449
  };
230
450
 
231
- const ExpandCollapseButton = ({ element }) => {
232
- const button = cloneElement('expand-collapse-button');
451
+ const CommonField = ({ template, omitLabel, schema, state, path, floating = true }) => {
452
+ const element = cloneElement(template);
453
+ const placeholder = schema.placeholder || schema.shape?.placeholder || ' ';
233
454
 
234
- button.onclick = (ev) => {
235
- ev.stopPropagation();
236
- element.classList.toggle('collapsed');
455
+ element.id = state;
237
456
 
238
- if (element.classList.contains('collapsed')) {
239
- button.innerText = 'Show';
240
- } else {
241
- button.innerText = 'Hide';
242
- }
457
+ if (placeholder) element.placeholder = placeholder;
243
458
 
244
- return false;
245
- };
459
+ element.value = store.getValue(state, schema);
246
460
 
247
- return button;
461
+ if (omitLabel) return element;
462
+
463
+ const group = cloneElement('form-group-floating');
464
+
465
+ if (floating) {
466
+ group.append(element);
467
+ group.append(Label({ title: schema.name, labelFor: element }));
468
+ } else {
469
+ group.append(Label({ title: schema.name, labelFor: element }));
470
+ group.append(element);
471
+ }
472
+
473
+ return group;
474
+ };
475
+
476
+ const DateTimeField = ({ omitLabel, schema, state, path }) => {
477
+ return CommonField({ template: 'datetime-field', floating: false, omitLabel, schema, state, path });
478
+ };
479
+
480
+ const DateField = ({ omitLabel, schema, state, path }) => {
481
+ return CommonField({ template: 'date-field', floating: false, omitLabel, schema, state, path });
248
482
  };
249
483
 
250
- const DeleteButton = ({ rootElement, template = 'delete-button' }) => {
484
+ const TimeField = ({ omitLabel, schema, state, path }) => {
485
+ return CommonField({ template: 'time-field', floating: false, omitLabel, schema, state, path });
486
+ };
487
+
488
+ const IntegerField = ({ omitLabel, schema, state, path }) => {
489
+ return CommonField({ template: 'integer-field', omitLabel, schema, state, path });
490
+ };
491
+
492
+ const FloatField = ({ omitLabel, schema, state, path }) => {
493
+ return CommonField({ template: 'float-field', omitLabel, schema, state, path });
494
+ };
495
+
496
+ const DecimalField = ({ omitLabel, schema, state, path }) => {
497
+ return CommonField({ template: 'decimal-field', omitLabel, schema, state, path });
498
+ };
499
+
500
+ const DeleteButton = ({ path, state, rootElement, template = 'delete-button' }) => {
251
501
  const element = cloneElement(template);
252
502
 
253
503
  element.onclick = (ev) => {
254
- ev.stopPropagation();
255
- rootElement.remove();
504
+ ev.preventDefault();
505
+ rootElement.remove(); // TODO: Handle confirmation callback.
506
+ store.deleteValue(state);
256
507
 
257
508
  return false;
258
509
  };
@@ -260,27 +511,20 @@ ActiveElement.JsonField = (() => {
260
511
  return element;
261
512
  };
262
513
 
263
- const AppendButton = ({ list, schema, state }) => {
514
+ const AppendButton = ({ list, schema, state, path: objectPath }) => {
264
515
  const element = cloneElement('append-button');
265
-
266
- const humanName = humanize({ string: schema.name, singular: true });
516
+ const humanName = humanize({ string: schema.name || fieldName, singular: true });
267
517
 
268
518
  element.append(`Add ${humanName}`);
519
+ element.classList.add('append-button', 'float-end');
269
520
  element.onclick = (ev) => {
270
- ev.stopPropagation();
271
- const listItem = cloneElement('list-item');
272
- const objectField = ObjectField(
273
- { name: schema.name, omitLabel: true, state, schema: { ...schema, ...schema.shape } }
274
- );
521
+ ev.preventDefault();
275
522
 
276
- if (schema.shape.type == 'object') {
277
- listItem.append(DeleteButton({ rootElement: listItem, template: 'delete-object-button' }));
278
- listItem.append(objectField);
279
- } else {
280
- listItem.append(objectField);
281
- listItem.append(DeleteButton({ rootElement: listItem }));
282
- }
283
- list.append(listItem);
523
+ const { path, state: appendState } = store.appendValue({ path: objectPath, schema });
524
+ const item = ArrayItem({ path, state: appendState, schema, newItem: true })
525
+
526
+ list.append(item);
527
+ item.scrollIntoView();
284
528
 
285
529
  return false;
286
530
  };
@@ -288,18 +532,30 @@ ActiveElement.JsonField = (() => {
288
532
  return element;
289
533
  };
290
534
 
291
- element.append(ObjectField({ omitLabel: true, schema, state, getValue }));
535
+ element.append(ObjectField({ schema, omitLabel: true, state: store.state, path: [] }));
292
536
  };
293
537
 
294
538
  const JsonField = (element) => {
295
539
  const data = getData(element);
540
+ const formId = element.dataset.formId;
541
+ const formFieldElement = document.querySelector(`#${element.dataset.fieldId}`);
542
+ const schemaFieldElement = document.querySelector(`#${element.dataset.schemaFieldId}`);
543
+ const fieldName = element.dataset.fieldName;
296
544
  const schema = getSchema(element);
297
- const { state, store, getValue } = createStore({ data });
545
+ const { store, stateChanged, connectState } = createStore({ data, schema });
546
+
547
+ schemaFieldElement.value = JSON.stringify(schema);
548
+
549
+ stateChanged(({ getState }) => {
550
+ const state = getState();
551
+
552
+ formFieldElement.value = JSON.stringify(state);
553
+ ActiveElement.log.debug(state);
554
+ });
298
555
 
299
- console.log(getState({ store }));
556
+ connectState({ element });
300
557
 
301
- trackState({ element, schema, getValue });
302
- const component = Component({ getValue, schema, state, element });
558
+ const component = Component({ store, stateChanged, connectState, schema, element, fieldName });
303
559
 
304
560
  return component;
305
561
  };