active_element 0.0.9 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
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 +111 -78
  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 +170 -31
  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 +17 -2
  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 -7
  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
  };