diff --git a/docs/api/data_source_manager.md b/docs/api/data_source_manager.md index 0419f1530..b044ad288 100644 --- a/docs/api/data_source_manager.md +++ b/docs/api/data_source_manager.md @@ -130,7 +130,7 @@ data record, and optional property path. Store data sources to a JSON object. -Returns **[Object][6]** Stored data sources. +Returns **[Array][8]** Stored data sources. ## load @@ -155,3 +155,5 @@ Returns **[Object][6]** Loaded data sources. [6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object [7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String + +[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array diff --git a/docs/modules/DataSources.md b/docs/modules/DataSources.md index 80ab71700..aaec2243c 100644 --- a/docs/modules/DataSources.md +++ b/docs/modules/DataSources.md @@ -211,6 +211,30 @@ console.log(loadedDataSource.getRecord('id1').get('content')); // Outputs: "This Remember that DataSources with `skipFromStorage: true` will not be available after a project is loaded unless you add them programmatically. + +## Record Mutability + +DataSource records are mutable by default, but can be set as immutable to prevent modifications. Use the mutable flag when creating records to control this behavior. + +```ts +const dataSource = { + id: 'my-datasource', + records: [ + { id: 'id1', content: 'Mutable content' }, + { id: 'id2', content: 'Immutable content', mutable: false }, + ], +}; + + +editor.DataSources.add(dataSource); + +const ds = editor.DataSources.get('my-datasource'); +ds.getRecord('id1').set('content', 'Updated content'); // Succeeds +ds.getRecord('id2').set('content', 'New content'); // Throws error +``` + +Immutable records cannot be modified or removed, ensuring data integrity for critical information. + ## Benefits of Using DataSources DataSources are integrated with GrapesJS's runtime and BackboneJS models, enabling dynamic updates and synchronization between your data and UI components. This allows you to: diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index eb6ec2f9e..c46a7dcb8 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -18,7 +18,7 @@ export type UndoOptions = { fromUndo?: boolean }; export type WithHTMLParserOptions = { parserOptions?: HTMLParserOptions }; -export type RemoveOptions = Backbone.Silenceable & UndoOptions; +export type RemoveOptions = Backbone.Silenceable & UndoOptions & { dangerously?: boolean }; export type EventHandler = Backbone.EventHandler; diff --git a/packages/core/src/data_sources/model/DataRecord.ts b/packages/core/src/data_sources/model/DataRecord.ts index bf64dbfcf..0ddbc7b64 100644 --- a/packages/core/src/data_sources/model/DataRecord.ts +++ b/packages/core/src/data_sources/model/DataRecord.ts @@ -32,8 +32,11 @@ import EditorModel from '../../editor/model/Editor'; import { _StringKey } from 'backbone'; export default class DataRecord extends Model { + public mutable: boolean; + constructor(props: T, opts = {}) { super(props, opts); + this.mutable = props.mutable ?? true; this.on('change', this.handleChange); } @@ -137,6 +140,10 @@ export default class DataRecord ext options?: SetOptions | undefined, ): this; set(attributeName: unknown, value?: unknown, options?: SetOptions): DataRecord { + if (!this.isNew() && this.attributes.mutable === false) { + throw new Error('Cannot modify immutable record'); + } + const onRecordSetValue = this.dataSource?.transformers?.onRecordSetValue; const applySet = (key: string, val: unknown) => { diff --git a/packages/core/src/data_sources/model/DataSource.ts b/packages/core/src/data_sources/model/DataSource.ts index 37543901b..f64ff1e99 100644 --- a/packages/core/src/data_sources/model/DataSource.ts +++ b/packages/core/src/data_sources/model/DataSource.ts @@ -152,6 +152,11 @@ export default class DataSource extends Model { * @name removeRecord */ removeRecord(id: string | number, opts?: RemoveOptions): DataRecord | undefined { + const record = this.getRecord(id); + if (record?.mutable === false && !opts?.dangerously) { + throw new Error('Cannot remove immutable record'); + } + return this.records.remove(id, opts); } diff --git a/packages/core/src/data_sources/types.ts b/packages/core/src/data_sources/types.ts index ecbbedeab..3b23326e6 100644 --- a/packages/core/src/data_sources/types.ts +++ b/packages/core/src/data_sources/types.ts @@ -7,6 +7,11 @@ export interface DataRecordProps extends ObjectAny { * Record id. */ id: string; + + /** + * Specifies if the record is mutable. Defaults to `true`. + */ + mutable?: boolean; } export interface DataVariableListener { diff --git a/packages/core/test/specs/data_sources/mutable.ts b/packages/core/test/specs/data_sources/mutable.ts new file mode 100644 index 000000000..bf3bf0948 --- /dev/null +++ b/packages/core/test/specs/data_sources/mutable.ts @@ -0,0 +1,128 @@ +import DataSourceManager from '../../../src/data_sources'; +import { setupTestEditor } from '../../common'; +import EditorModel from '../../../src/editor/model/Editor'; + +describe('DataSource Immutability', () => { + let em: EditorModel; + let dsm: DataSourceManager; + + beforeEach(() => { + ({ em, dsm } = setupTestEditor()); + }); + + afterEach(() => { + em.destroy(); + }); + + test('set throws error for immutable record', () => { + const ds = dsm.add({ + id: 'testDs1', + records: [{ id: 'id1', name: 'Name1', value: 100, mutable: false }], + }); + const record = ds.getRecord('id1'); + + expect(() => record?.set('name', 'UpdatedName')).toThrow('Cannot modify immutable record'); + expect(record?.get('name')).toBe('Name1'); + }); + + test('set throws error for multiple attributes on immutable record', () => { + const ds = dsm.add({ + id: 'testDs2', + records: [{ id: 'id1', name: 'Name1', value: 100, mutable: false }], + }); + const record = ds.getRecord('id1'); + + expect(() => record?.set({ name: 'UpdatedName', value: 150 })).toThrow('Cannot modify immutable record'); + expect(record?.get('name')).toBe('Name1'); + expect(record?.get('value')).toBe(100); + }); + + test('removeRecord throws error for immutable record', () => { + const ds = dsm.add({ + id: 'testDs3', + records: [{ id: 'id1', name: 'Name1', value: 100, mutable: false }], + }); + + expect(() => ds.removeRecord('id1')).toThrow('Cannot remove immutable record'); + expect(ds.getRecord('id1')).toBeTruthy(); + }); + + test('addRecord creates an immutable record', () => { + const ds = dsm.add({ + id: 'testDs4', + records: [], + }); + + ds.addRecord({ id: 'id1', name: 'Name1', value: 100, mutable: false }); + const newRecord = ds.getRecord('id1'); + + expect(() => newRecord?.set('name', 'UpdatedName')).toThrow('Cannot modify immutable record'); + expect(newRecord?.get('name')).toBe('Name1'); + }); + + test('setRecords replaces all records with immutable ones', () => { + const ds = dsm.add({ + id: 'testDs5', + records: [], + }); + + ds.setRecords([ + { id: 'id1', name: 'Name1', value: 100, mutable: false }, + { id: 'id2', name: 'Name2', value: 200, mutable: false }, + ]); + + const record1 = ds.getRecord('id1'); + const record2 = ds.getRecord('id2'); + + expect(() => record1?.set('name', 'UpdatedName1')).toThrow('Cannot modify immutable record'); + expect(() => record2?.set('name', 'UpdatedName2')).toThrow('Cannot modify immutable record'); + expect(record1?.get('name')).toBe('Name1'); + expect(record2?.get('name')).toBe('Name2'); + }); + + test('batch update throws error for immutable records', () => { + const ds = dsm.add({ + id: 'testDs6', + records: [ + { id: 'id1', name: 'Name1', value: 100, mutable: false }, + { id: 'id2', name: 'Name2', value: 200, mutable: false }, + ], + }); + + expect(() => { + ds.records.set([ + { id: 'id1', name: 'BatchUpdate1' }, + { id: 'id2', name: 'BatchUpdate2' }, + ]); + }).toThrow('Cannot modify immutable record'); + + expect(ds.getRecord('id1')?.get('name')).toBe('Name1'); + expect(ds.getRecord('id2')?.get('name')).toBe('Name2'); + }); + + test('nested property update throws error for immutable record', () => { + const ds = dsm.add({ + id: 'testDs7', + records: [{ id: 'nested-id', nested: { prop: 'NestedValue' }, mutable: false }], + }); + const record = ds.getRecord('nested-id'); + + expect(() => record?.set('nested.prop', 'UpdatedNestedValue')).toThrow('Cannot modify immutable record'); + }); + + test('record remains immutable after serialization and deserialization', () => { + const ds = dsm.add({ + id: 'testDs8', + records: [{ id: 'id1', name: 'Name1', value: 100, mutable: false }], + }); + const serialized = JSON.parse(JSON.stringify(ds.toJSON())); + + dsm.remove(ds.id as string); + const newDs = dsm.add(serialized); + + const record = newDs.getRecord('id1'); + + expect(() => record?.set('name', 'SerializedUpdate')).toThrow('Cannot modify immutable record'); + expect(record?.get('name')).toBe('Name1'); + }); +});