observablejson.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. JSONExt, JSONObject, JSONValue
  5. } from '@phosphor/coreutils';
  6. import {
  7. Message
  8. } from '@phosphor/messaging';
  9. import {
  10. Widget
  11. } from '@phosphor/widgets';
  12. import {
  13. h, VirtualDOM
  14. } from '@phosphor/virtualdom';
  15. import {
  16. CodeEditor
  17. } from '../codeeditor';
  18. import {
  19. IObservableMap, ObservableMap
  20. } from './observablemap';
  21. /**
  22. * The class name added to a ObservableJSONWidget instance.
  23. */
  24. const METADATA_CLASS = 'jp-ObservableJSONWidget';
  25. /**
  26. * The class name added when the Metadata editor contains invalid JSON.
  27. */
  28. const ERROR_CLASS = 'jp-mod-error';
  29. /**
  30. * The class name added to the editor host node.
  31. */
  32. const HOST_CLASS = 'jp-ObservableJSONWidget-host';
  33. /**
  34. * The class name added to the button area.
  35. */
  36. const BUTTON_AREA_CLASS = 'jp-ObservableJSONWidget-buttons';
  37. /**
  38. * The class name added to the revert button.
  39. */
  40. const REVERT_CLASS = 'jp-ObservableJSONWidget-revertButton';
  41. /**
  42. * The class name added to the commit button.
  43. */
  44. const COMMIT_CLASS = 'jp-ObservableJSONWidget-commitButton';
  45. /**
  46. * An observable JSON value.
  47. */
  48. export
  49. interface IObservableJSON extends IObservableMap<JSONValue> {
  50. /**
  51. * Serialize the model to JSON.
  52. */
  53. toJSON(): JSONObject;
  54. }
  55. /**
  56. * The namespace for IObservableJSON related interfaces.
  57. */
  58. export
  59. namespace IObservableJSON {
  60. /**
  61. * A type alias for observable JSON changed args.
  62. */
  63. export
  64. type IChangedArgs = ObservableMap.IChangedArgs<JSONValue>;
  65. }
  66. /**
  67. * A concrete Observable map for JSON data.
  68. */
  69. export
  70. class ObservableJSON extends ObservableMap<JSONValue> {
  71. /**
  72. * Construct a new observable JSON object.
  73. */
  74. constructor(options: ObservableJSON.IOptions = {}) {
  75. super({
  76. itemCmp: Private.itemCmp,
  77. values: options.values
  78. });
  79. }
  80. /**
  81. * Serialize the model to JSON.
  82. */
  83. toJSON(): JSONObject {
  84. let out: JSONObject = Object.create(null);
  85. for (let key of this.keys()) {
  86. let value = this.get(key);
  87. if (JSONExt.isPrimitive(value)) {
  88. out[key] = value;
  89. } else {
  90. out[key] = JSON.parse(JSON.stringify(value));
  91. }
  92. }
  93. return out;
  94. }
  95. }
  96. /**
  97. * The namespace for ObservableJSON static data.
  98. */
  99. export
  100. namespace ObservableJSON {
  101. /**
  102. * The options use to initialize an observable JSON object.
  103. */
  104. export
  105. interface IOptions {
  106. /**
  107. * The optional intitial value for the object.
  108. */
  109. values?: JSONObject;
  110. }
  111. /**
  112. * An observable JSON change message.
  113. */
  114. export
  115. class ChangeMessage extends Message {
  116. /**
  117. * Create a new metadata changed message.
  118. */
  119. constructor(args: IObservableJSON.IChangedArgs) {
  120. super('jsonvalue-changed');
  121. this.args = args;
  122. }
  123. /**
  124. * The arguments of the change.
  125. */
  126. readonly args: IObservableJSON.IChangedArgs;
  127. }
  128. }
  129. /**
  130. * A widget for editing observable JSON.
  131. */
  132. export
  133. class ObservableJSONWidget extends Widget {
  134. /**
  135. * Construct a new metadata editor.
  136. */
  137. constructor(options: ObservableJSONWidget.IOptions) {
  138. super({ node: Private.createEditorNode() });
  139. this.addClass(METADATA_CLASS);
  140. let host = this.editorHostNode;
  141. let model = new CodeEditor.Model();
  142. model.value.text = 'No data!';
  143. model.mimeType = 'application/json';
  144. model.value.changed.connect(this._onValueChanged, this);
  145. this.model = model;
  146. this.editor = options.editorFactory({ host, model });
  147. }
  148. /**
  149. * The code editor used by the editor.
  150. */
  151. readonly editor: CodeEditor.IEditor;
  152. /**
  153. * The code editor model used by the editor.
  154. */
  155. readonly model: CodeEditor.IModel;
  156. /**
  157. * Get the editor host node used by the metadata editor.
  158. */
  159. get editorHostNode(): HTMLElement {
  160. return this.node.getElementsByClassName(HOST_CLASS)[0] as HTMLElement;
  161. }
  162. /**
  163. * Get the revert button used by the metadata editor.
  164. */
  165. get revertButtonNode(): HTMLElement {
  166. return this.node.getElementsByClassName(REVERT_CLASS)[0] as HTMLElement;
  167. }
  168. /**
  169. * Get the commit button used by the metadata editor.
  170. */
  171. get commitButtonNode(): HTMLElement {
  172. return this.node.getElementsByClassName(COMMIT_CLASS)[0] as HTMLElement;
  173. }
  174. /**
  175. * The observable source.
  176. */
  177. get source(): IObservableJSON | null {
  178. return this._source;
  179. }
  180. set source(value: IObservableJSON | null) {
  181. if (this._source === value) {
  182. return;
  183. }
  184. if (this._source) {
  185. this._source.changed.disconnect(this._onSourceChanged, this);
  186. }
  187. this._source = value;
  188. value.changed.connect(this._onSourceChanged, this);
  189. this._setValue();
  190. }
  191. /**
  192. * Get whether the editor is dirty.
  193. */
  194. get isDirty(): boolean {
  195. return this._dataDirty || this._inputDirty;
  196. }
  197. /**
  198. * Handle the DOM events for the widget.
  199. *
  200. * @param event - The DOM event sent to the widget.
  201. *
  202. * #### Notes
  203. * This method implements the DOM `EventListener` interface and is
  204. * called in response to events on the notebook panel's node. It should
  205. * not be called directly by user code.
  206. */
  207. handleEvent(event: Event): void {
  208. switch (event.type) {
  209. case 'blur':
  210. this._evtBlur(event as FocusEvent);
  211. break;
  212. case 'click':
  213. this._evtClick(event as MouseEvent);
  214. break;
  215. default:
  216. break;
  217. }
  218. }
  219. /**
  220. * Handle `after-attach` messages for the widget.
  221. */
  222. protected onAfterAttach(msg: Message): void {
  223. let node = this.editorHostNode;
  224. node.addEventListener('blur', this, true);
  225. this.revertButtonNode.hidden = true;
  226. this.commitButtonNode.hidden = true;
  227. this.revertButtonNode.addEventListener('click', this);
  228. this.commitButtonNode.addEventListener('click', this);
  229. }
  230. /**
  231. * Handle `before_detach` messages for the widget.
  232. */
  233. protected onBeforeDetach(msg: Message): void {
  234. let node = this.editorHostNode;
  235. node.removeEventListener('blur', this, true);
  236. this.revertButtonNode.removeEventListener('click', this);
  237. this.commitButtonNode.removeEventListener('click', this);
  238. }
  239. /**
  240. * Handle a change to the metadata of the source.
  241. */
  242. private _onSourceChanged(sender: IObservableJSON, args: IObservableJSON.IChangedArgs) {
  243. if (this._changeGuard) {
  244. return;
  245. }
  246. if (this._inputDirty || this.editor.hasFocus()) {
  247. this._dataDirty = true;
  248. return;
  249. }
  250. this._setValue();
  251. }
  252. /**
  253. * Handle change events.
  254. */
  255. private _onValueChanged(): void {
  256. let valid = true;
  257. try {
  258. let value = JSON.parse(this.editor.model.value.text);
  259. this.removeClass(ERROR_CLASS);
  260. this._inputDirty = (
  261. !this._changeGuard && !JSONExt.deepEqual(value, this._originalValue)
  262. );
  263. } catch (err) {
  264. this.addClass(ERROR_CLASS);
  265. this._inputDirty = true;
  266. valid = false;
  267. }
  268. this.revertButtonNode.hidden = !this._inputDirty;
  269. this.commitButtonNode.hidden = !valid || !this._inputDirty;
  270. }
  271. /**
  272. * Handle blur events for the text area.
  273. */
  274. private _evtBlur(event: FocusEvent): void {
  275. // Update the metadata if necessary.
  276. if (!this._inputDirty && this._dataDirty) {
  277. this._setValue();
  278. }
  279. }
  280. /**
  281. * Handle click events for the buttons.
  282. */
  283. private _evtClick(event: MouseEvent): void {
  284. let target = event.target as HTMLElement;
  285. if (target === this.revertButtonNode) {
  286. this._setValue();
  287. } else if (target === this.commitButtonNode) {
  288. if (!this.commitButtonNode.hidden && !this.hasClass(ERROR_CLASS)) {
  289. this._changeGuard = true;
  290. this._mergeContent();
  291. this._changeGuard = false;
  292. this._setValue();
  293. }
  294. }
  295. }
  296. /**
  297. * Merge the user content.
  298. */
  299. private _mergeContent(): void {
  300. let model = this.editor.model;
  301. let current = this._getContent() as JSONObject;
  302. let old = this._originalValue;
  303. let user = JSON.parse(model.value.text) as JSONObject;
  304. let source = this.source;
  305. // If it is in user and has changed from old, set in current.
  306. for (let key in user) {
  307. if (!JSONExt.deepEqual(user[key], old[key])) {
  308. current[key] = user[key];
  309. }
  310. }
  311. // If it was in old and is not in user, remove from current.
  312. for (let key in old) {
  313. if (!(key in user)) {
  314. delete current[key];
  315. source.delete(key);
  316. }
  317. }
  318. // Set the values.
  319. for (let key in current) {
  320. source.set(key, current[key]);
  321. }
  322. }
  323. /**
  324. * Get the metadata from the owner.
  325. */
  326. private _getContent(): JSONObject | undefined {
  327. let source = this._source;
  328. if (!source) {
  329. return void 0;
  330. }
  331. let content: JSONObject = {};
  332. for (let key of source.keys()) {
  333. content[key] = source.get(key);
  334. }
  335. return content;
  336. }
  337. /**
  338. * Set the value given the owner contents.
  339. */
  340. private _setValue(): void {
  341. this._dataDirty = false;
  342. this._inputDirty = false;
  343. this.revertButtonNode.hidden = true;
  344. this.commitButtonNode.hidden = true;
  345. this.removeClass(ERROR_CLASS);
  346. let model = this.editor.model;
  347. let content = this._getContent();
  348. this._changeGuard = true;
  349. if (content === void 0) {
  350. model.value.text = 'No data!';
  351. this._originalValue = {};
  352. } else {
  353. let value = JSON.stringify(content, null, 2);
  354. model.value.text = value;
  355. this._originalValue = content;
  356. }
  357. this.editor.refresh();
  358. this._changeGuard = false;
  359. this.commitButtonNode.hidden = true;
  360. this.revertButtonNode.hidden = true;
  361. }
  362. private _dataDirty = false;
  363. private _inputDirty = false;
  364. private _source: IObservableJSON | null = null;
  365. private _originalValue: JSONObject;
  366. private _changeGuard = false;
  367. }
  368. /**
  369. * The static namespace ObservableJSONWidget class statics.
  370. */
  371. export
  372. namespace ObservableJSONWidget {
  373. /**
  374. * The options used to initialize a metadata editor.
  375. */
  376. export
  377. interface IOptions {
  378. /**
  379. * The editor factory used by the tool.
  380. */
  381. editorFactory: CodeEditor.Factory;
  382. }
  383. }
  384. /**
  385. * The namespace for module private data.
  386. */
  387. namespace Private {
  388. /**
  389. * Create the node for the EditorWdiget.
  390. */
  391. export
  392. function createEditorNode(): HTMLElement {
  393. let cancelTitle = 'Revert changes to data';
  394. let confirmTitle = 'Commit changes to data';
  395. return VirtualDOM.realize(
  396. h.div({ className: METADATA_CLASS },
  397. h.div({ className: BUTTON_AREA_CLASS },
  398. h.span({ className: REVERT_CLASS, title: cancelTitle }),
  399. h.span({ className: COMMIT_CLASS, title: confirmTitle })),
  400. h.div({ className: HOST_CLASS }))
  401. );
  402. }
  403. /**
  404. * Compare two objects for JSON equality.
  405. */
  406. export
  407. function itemCmp(a: JSONValue, b: JSONValue): boolean {
  408. if (a === void 0 || b === void 0) {
  409. return false;
  410. }
  411. return JSONExt.deepEqual(a, b);
  412. }
  413. }