ExtJS - Integrated Form Serialization

I have a complex form that I cannot use in the form serialization method. There are many fields, as well as a dynamic grid (a grid that dynamically generates when a user selects certain criteria) inside the form.

What I want to do is collect user inputs / selections + add selected records available in the grid, and then finally make a JSON array with this data in order to be able to send server messages.

My guess is, I can use the getCmp ExtJS function to get the whole data, but as you might have guessed, this is a little difficult to maintain. Also, I have no idea to get grid data this way!

PS: The code is a little long, so I cut some parts.

DEFINITIONS OF MODEL AND STORE

 Ext.Loader.setConfig({enabled: true}); Ext.Loader.setPath('Ext.ux', '<?php echo js_url(); ?>resources/ux'); Ext.require([ 'Ext.grid.*', 'Ext.data.*', 'Ext.form.*', 'Ext.state.*', 'Ext.util.*', 'Ext.layout.container.Column', 'Ext.selection.CheckboxModel', 'Ext.ux.RowExpander', 'Ext.ux.statusbar.StatusBar' ]); var navigate = function (panel, direction) { var layout = panel.getLayout(); layout[direction](); Ext.getCmp('move-prev').setDisabled(!layout.getPrev()); Ext.getCmp('move-next').setDisabled(!layout.getNext()); }; // Article Model Ext.define('Article', { extend: 'Ext.data.Model', fields: [ {name: 'ARTICLE_ID', type: 'int'}, {name: 'DESCRIPTION', type: 'string'} ] }); // Article Json Store var articles = new Ext.data.JsonStore({ model: 'Article', proxy: { type: 'ajax', url: '<?php echo base_url() ?>create/get_articles', reader: { type: 'json', root: 'artList', idProperty: 'ARTICLE_ID' } } }); winArticle = new Ext.Window({ width: 600, modal: true, title: 'Artikel Seçimi', closeAction: 'hide', bodyPadding: 10, items: new Ext.Panel({ items: [ { xtype: 'fieldset', title: 'Artikel Sorgulama', defaultType: 'textfield', layout: 'anchor', defaults: { anchor: '100%' }, height: '76px', items: [ { xtype: 'fieldcontainer', layout: 'hbox', defaultType: 'textfield', items: [ { xtype: 'combobox', id: 'articleNo', inputWidth: 320, fieldLabel: 'ARTİKEL NO', fieldStyle: 'height: 26px', margin: '10 15 0 0', triggerAction: 'query', pageSize: true }, { xtype: 'button', text: 'SORGULA', width: 100, scale: 'medium', margin: '8 0 0 0' } ] } ] }, { xtype: 'fieldset', title: 'Artikel Bilgileri', height: '140px', layout: 'fit', items: [ { xtype: 'fieldcontainer', layout: 'hbox', defaultType: 'textfield', fieldDefaults: { labelAlign: 'top' }, items: [ { fieldLabel: 'ARTİKEL TANIMI', name: 'artDesc', flex: 3, margins: '0 5 0 0' }, { fieldLabel: 'PAKET İÇERİĞİ', name: 'artgebi', flex: 1 } ] }, { xtype: 'fieldcontainer', layout: 'hbox', defaultType: 'textfield', id: 'artContainer1', fieldDefaults: { labelAlign: 'top' }, items: [ { fieldLabel: 'SUBSYS', name: 'artSubsys', flex: 1, margins: '0 5 0 0' }, { fieldLabel: 'VARIANT', name: 'artVariant', flex: 1, margins: '0 5 0 0' }, { fieldLabel: 'VARIANT TANIMI', name: 'artVariantDesc', flex: 2 } ] } ] }, { xtype: 'fieldset', title: 'Aksiyon Seviyeleri', id: 'article-fieldset', items: [ { xtype: 'button', id: 'btnArticleLevelAdd', text: 'LEVEL EKLE', scale: 'medium', width: 100, style: 'float: right', margin: '0 7 0 0', handler: function() { var getLevels = function() { var count = winArticle.down('fieldset[id=article-fieldset]').items.items.length; return count; } var count = getLevels(); if (count === 3) { Ext.getCmp('btnArticleLevelAdd').disable(); } var container = 'artContainer' + count; //console.log(container); Ext.getCmp('article-fieldset').add([ { xtype: 'fieldcontainer', layout: 'hbox', id: 'artContainer' + count, defaultType: 'textfield', fieldDefaults: { labelAlign: 'top' }, items: [ { name: 'artLevel' + count, allowBlank: false, inputWidth: 216, fieldStyle: 'text-align: right; font-size: 13pt; background-color: #EAFFCC;', margins: '0 5 5 0' }, { name: 'artValue' + count, allowBlank: false, inputWidth: 216, fieldStyle: 'text-align: right; font-size: 13pt; background-color: #EAFFCC;', margins: '0 5 0 0' }, { xtype: 'button', text: 'SİL', width: 40, cls: 'btn-article-remove', handler: function() { if(count <= 3) { Ext.getCmp('btnArticleLevelAdd').enable(); } else { Ext.getCmp('btnArticleLevelAdd').disable(); } winArticle.down('fieldset[id=article-fieldset]').remove(container); } } ] } ]); } }, { xtype: 'fieldcontainer', layout: 'hbox', id: 'article-level-container', defaultType: 'textfield', fieldDefaults: { labelAlign: 'top' }, items: [ { fieldLabel: 'LEVEL', name: 'artLevel', inputWidth: 216, margins: '0 5 5 0', allowBlank: false, fieldStyle: 'text-align: right; font-size: 13pt; background-color: #EAFFCC;' }, { fieldLabel: 'VALUE', name: 'artValue', inputWidth: 216, allowBlank: false, blankText: 'zorunlu alan, boş bırakılamaz', fieldStyle: 'text-align: right; font-size: 13pt; background-color: #EAFFCC;', listeners: { change: function(textfield, newValue, oldValue) { if(oldValue == 'undefined' || newValue == '') { Ext.getCmp('btnArticleSave').disable(); } else { Ext.getCmp('btnArticleSave').enable(); } } } } ] } ] } ] }), buttons: [ { text: 'KAPAT', scale: 'medium', width: 100, cls: 'btn-article-close', listeners: { click: function() { winArticle.close(); } } }, '->', { text: 'EKLE', scale: 'medium', disabled: true, width: 100, margin: '0 9 0 0', cls: 'btn-article-save', id: 'btnArticleSave' } ] }); 

EXT.ONREADY FUNCTION

 Ext.onReady(function () { Ext.QuickTips.init(); Ext.state.Manager.setProvider(new Ext.state.CookieProvider({ expires: new Date(new Date().getTime() + (1000 * 60 * 60 * 24 * 7)) })); var Discounts = Ext.create('Ext.form.Panel', { id: 'discount-types', bodyPadding: 10, width: 760, height: 600, title: 'DNR TANIMLAMA / SCREEN 0', layout: 'card', bodyStyle: 'padding:20px', defaults: { border: false, anchor: '100%' }, style: { 'box-shadow': '0 2px 5px rgba(0, 0, 0, 0.6)', '-webkit-box-shadow': '0 0 8px rgba(0, 0, 0, 0.5)' }, frame: true, buttons: [ { text: 'ÖNCEKİ ADIM', id: 'move-prev', cls: 'np-button', scale: 'medium', iconCls: 'dnr-prev-icon', iconAlign: 'left', handler: function (btn) { navigate(btn.up('panel'), 'prev'); var itemd = Discounts.getLayout().getActiveItem(); Discounts.setTitle('DNR TANIMLAMA ' + ' / ' + itemd.cardTitle); Ext.getCmp('dnr-submit').disable(); Ext.getCmp('dnr-submit').setVisible(false); }, disabled: true }, { text: 'SONRAKİ ADIM', id: 'move-next', scale: 'medium', cls: 'np-button', iconCls: 'dnr-next-icon', iconAlign: 'right', handler: function (btn) { navigate(btn.up('panel'), 'next'); var itemd = Discounts.getLayout().getActiveItem(); Discounts.setTitle('DNR TANIMLAMA ' + ' / ' + itemd.cardTitle); var cardNum = Discounts.items.indexOf(itemd); if (cardNum == 3) { Ext.getCmp('dnr-submit').enable(); Ext.getCmp('dnr-submit').setVisible(true); } }, disabled: true }, '->', { text: '&nbsp KAYDET ', id: 'dnr-submit', scale: 'medium', iconCls: 'dnr-submit-icon', iconAlign: 'right', cls: 'dnr-submit', disabled: true, hidden: true, handler: function (btn) { } } ], items: [ { id: 'screen-0', cardTitle: 'SCREEN 0', layout: 'form', items: [ { layout: { type: 'vbox', align: 'center' }, margin: '60 0 0 0', items: [ { xtype: 'combobox', inputWidth: 295, fieldLabel: 'DNR TİPİ', fieldStyle: 'height: 26px', id: 'discount-type', store: discounts, valueField: 'DNR_TYPE_ID', displayField: 'DNR_TYPE_DESC', queryMode: 'remote', forceSelection: true, stateful: true, stateId: 'cmb_disc_type', allowBlank: false, emptyText: 'DNR tipini seçiniz...', triggerAction: 'all', listeners: { select: function (e) { var discType = Ext.getCmp('discount-type').getValue(); var discDetail = Ext.getCmp('discount-detail'); discdetails.removeAll(); if (discType != 0) { discDetail.setDisabled(false); discdetails.proxy.extraParams = { 'dnrtype': discType }; discdetails.load(); } } } }, { xtype: 'combobox', inputWidth: 400, fieldStyle: 'height: 26px', id: 'discount-detail', valueField: 'ID', displayField: 'DNR_TITLE', store: discdetails, forceSelection: true, stateful: true, stateId: 'cmb_disc_detail', margin: '25 0 0 0', disabled: true, allowBlank: false, msgTarget: 'side', emptyText: 'İNDİRİM TİPİNİ SEÇİNİZ...', blankText: 'İndirim tipi boş olamaz!', triggerAction: 'all', listeners: { select: function (e) { var discDetail = Ext.getCmp('discount-detail').getValue(); if (discDetail != 'null') { var value = discdetails.getAt(discdetails.find('ID', discDetail)).get('DNR_DESCRIPTION'); Ext.getCmp('dnr-type-desc-panel').setVisible(true); Ext.getCmp('dnr-type-desc-panel').update(value); } } } }, { xtype: 'textarea', grow: false, name: 'invoiceText', fieldLabel: 'FATURA METNİ', id: 'invoice-text', blankText: 'Fatura metni boş olamaz!', width: 400, height: 60, margin: '30 0 0 0', allowBlank: false, msgTarget: 'side', listeners: { change: function (e) { if (Ext.getCmp('invoice-text').getValue().length === 0) { Ext.getCmp('move-next').disable(); } else { Ext.getCmp('move-next').enable(); } } } }, { xtype: 'panel', id: 'dnr-type-desc-panel', layout: {type: 'hbox', align: 'stretch'}, height: 145, width: 400, cls: 'dnr-desc-panel', margin: '60 0 0 0', html: '&nbsp', hidden: true } ] } ] }, { id: 'screen-1', cardTitle: 'SCREEN 1', layout: 'form', items: [ { layout: 'column', width: 730, height: 90, items: [ { xtype: 'fieldset', title: 'ARTİKEL / HEDEF GRUP / MAL GRUBU SEÇİMİ', cls: 'dnr-fieldset', width: 730, height: 80, margin: '0', items: [ { xtype: 'buttongroup', columns: 5, columnWidth: 140, frame: false, margin: '5 0 0 18', items: [ { text: 'ARTİKEL', scale: 'medium', margin: '0 18px 0 0', width: 120, height: 36, id: 'btn-article', cls: 'btn-grp-choose btn-grp-article', listeners: { click: function () { winArticle.center(); winArticle.show(); } } }, { text: 'PUAR', scale: 'medium', margin: '0 18px 0 0', width: 120, height: 36, cls: 'btn-grp-choose btn-grp-puar', listeners: { click: function() { winPuar.show(); } } }, { text: 'MAL GRUBU', scale: 'medium', margin: '0 18px 0 0', width: 120, height: 36, cls: 'btn-grp-choose btn-grp-choose', listeners: { click: function() { winArticleGroup.show(); } } }, { text: 'HEDEF GRUP', scale: 'medium', margin: '0 18px 0 0', width: 120, height: 36, cls: 'btn-grp-choose btn-grp-target', listeners: { click: function() { winTargetGroup.show(); } } }, { text: 'SUPPLIER', scale: 'medium', width: 120, height: 36, cls: 'btn-grp-choose btn-grp-supplier', listeners: { click: function() { winSupplier.show(); } } } ] } ] } ] }, { xtype: 'gridpanel', id: 'article-grid', selType: 'rowmodel', elStatus: true, plugins: [ { ptype: 'cellediting', clicksToEdit: 1}, { ptype: 'datadrop'} ], /* *************************************************************** * here is the tricky part! when user change any fields above * this grid will dynamically generate upon user request. So that * we arent sure which columns will be available. * ***************************************************************/ columns: [ { text: 'COLUMN A', dataIndex: '' } ] } ] }, renderTo: 'content' }) }); 
+4
source share
2 answers

Updated Answer

After some clarification, I think the answer should be pretty easy (at least I think so). For the following answer, I assume that you are inside the form at the time you want to receive form and grid data, and that there is only one Ext.form.Panel :

 // Navigate up to the form: var form = this.up('form'), // get the form values data = form.getValues(), // get the selected record from the grid gridRecords = form.down('grid').getSelectionModel().getSelected(), // some helper variables len = gridRecords.length, recordData = []; // normalize the model data by copying just the data objects into the array for(i=0;i<len;i++) { recordData .push(gridRecords[i].data); } // apply the selected grid records to the formdata. For that you will need a property name, I will use just 'gridRecords' but you may change it data.gridRecords = recordData; // send all back via a ajax request Ext.Ajax.request({ url: 'demo/sample', success: function(response, opts) { // your handler }, failure: function(response, opts) { // your handler }, jsonData: data }); 

It should be

To provide additional data parameters that can be obtained from / using the grid

 // get all data that is currently in the store form.down('grid').getStore().data.items // get all new and updated records form.down('grid').getStore().getModifiedRecords() // get all new records form.down('grid').getStore().getNewRecords() // get all updated records form.down('grid').getStore().getUpdatedRecords() 

Old answer (for more complex scenarios) below

What you said:

You have a grid with shapes and possibly grids. Where you also need to read grids when retrieving data from a form.

In the answer below I will just talk about getValues, binding / unbinding events to each grid, not

  • load / submit form
  • Download / Update Records
  • setting values

My recommendation is to make your form smarter so that it can handle it.

What do I mean by?

The default form takes care of all the fields that are inserted anywhere in the body. At 99.9%, this is fine, but not for everyone. Your form should also take care of the grids that are inserted.

How can I do that

First of all, when you make your parts of form grids, I recommend giving them a name property. Secondly, you need to know how a form is formed and uses fields so that you can copy this for grids. To do this, you need to take a look at the Ext.form.Basic constructor class, where the important part of this

 // We use the monitor here as opposed to event bubbling. The problem with bubbling is it doesn't // let us react to items being added/remove at different places in the hierarchy which may have an // impact on the dirty/valid state. me.monitor = new Ext.container.Monitor({ selector: '[isFormField]', scope: me, addHandler: me.onFieldAdd, removeHandler: me.onFieldRemove }); me.monitor.bind(owner); 

What happens here is that the monitor is initialized by looking for any field that will be inserted into the associated component, where the monitor will call the appropriate handler. The monitor is currently looking for fields, but you will need one that is looking for grids. Such a monitor will look like this:

 me.gridMonitor = new Ext.container.Monitor({ selector: 'grid', scope: me, addHandler: me.onGridAdd, removeHandler: me.onGridRemove }); me.gridMonitor.bind(owner); 

Since I know little about your data structure, I cannot tell you which gridevents you may need, but you have to register / unregister with addHandler / removeHandler, for example

 onGridAdd: function(grid) { var me = this; me.mon(grid,'select',me.yourHandler,me); }, onGridRemove: function(grid) { var me = this; me.mun(grid,'select',me.yourHandler,me); } 

In addition, you will need the following helper methods

 /** * Return all the {@link Ext.grid.Panel} components in the owner container. * @return {Ext.util.MixedCollection} Collection of the Grid objects */ getGrids: function() { return this.gridMonitor.getItems(); }, /** * Find a specific {@link Ext.grid.Panel} in this form by id or name. * @param {String} id The value to search for (specify either a {@link Ext.Component#id id} or * {@link Ext.grid.Panel name }). * @return {Ext.grid.Panel} The first matching grid, or `null` if none was found. */ findGrid: function(id) { return this.getGrids().findBy(function(f) { return f.id === id || f.name === id; }); }, 

And most importantly, a method that receives data from grids. Here we need to redefine

 getValues: function(asString, dirtyOnly, includeEmptyText, useDataValues) { var values = {}, fields = this.getFields().items, grids = this.getGrids().items, // the grids found by the monitor f, fLen = fields.length, gLen = grids.length, // gridcount isArray = Ext.isArray, grid, gridData, gridStore, // some vars used while reading the grid content field, data, val, bucket, name; for (f = 0; f < fLen; f++) { field = fields[f]; if (!dirtyOnly || field.isDirty()) { data = field[useDataValues ? 'getModelData' : 'getSubmitData'](includeEmptyText); if (Ext.isObject(data)) { for (name in data) { if (data.hasOwnProperty(name)) { val = data[name]; if (includeEmptyText && val === '') { val = field.emptyText || ''; } if (values.hasOwnProperty(name)) { bucket = values[name]; if (!isArray(bucket)) { bucket = values[name] = [bucket]; } if (isArray(val)) { values[name] = bucket.concat(val); } else { bucket.push(val); } } else { values[name] = val; } } } } } } // begin new part for (g = 0; g < gLen; g++) { grid = grids[f]; gridStore = grid.getStore(); gridData = []; // You will need a identification variable to determine which data should be taken from the grid. Currently this demo implement three options // 0 only selected // 1 complete data within the store // 2 only modified records (this can be splitted to new and updated) var ditems = grid.submitData === 0 ? grid.getSelectionModel().getSelection() : grid.submitData === 1 ? gridStore.getStore().data.items : gridStore.getStore().getModifiedRecords(), dlen = ditems.length; for(d = 0; d < dLen; d++) { // push the model data to the current data list. It doesn't matter of which type the models (records) are, this will simply read the whole known data. Alternatively you may access the rawdata property if the reader does not know all fields. gridData.push(ditems[d].data); } // assign the array of record data to the grid-name property data[grid.name] = gridData; } // end new part if (asString) { values = Ext.Object.toQueryString(values); } return values; } 

Connects if it looks like

 Ext.define('Ext.ux.form.Basic', { extend: 'Ext.form.Basic', /** * Creates new form. * @param {Ext.container.Container} owner The component that is the container for the form, usually a {@link Ext.form.Panel} * @param {Object} config Configuration options. These are normally specified in the config to the * {@link Ext.form.Panel} constructor, which passes them along to the BasicForm automatically. */ constructor: function(owner, config) { var me = this; me.callParent(arguments); // We use the monitor here as opposed to event bubbling. The problem with bubbling is it doesn't // let us react to items being added/remove at different places in the hierarchy which may have an // impact on the dirty/valid state. me.gridMonitor = new Ext.container.Monitor({ selector: 'grid', scope: me, addHandler: me.onGridAdd, removeHandler: me.onGridRemove }); me.gridMonitor.bind(owner); }, onGridAdd: function(grid) { var me = this; me.mon(grid,'select',me.yourHandler,me); }, onGridRemove: function(grid) { var me = this; me.mun(grid,'select',me.yourHandler,me); }, /** * Return all the {@link Ext.grid.Panel} components in the owner container. * @return {Ext.util.MixedCollection} Collection of the Grid objects */ getGrids: function() { return this.gridMonitor.getItems(); }, /** * Find a specific {@link Ext.grid.Panel} in this form by id or name. * @param {String} id The value to search for (specify either a {@link Ext.Component#id id} or * {@link Ext.grid.Panel name }). * @return {Ext.grid.Panel} The first matching grid, or `null` if none was found. */ findGrid: function(id) { return this.getGrids().findBy(function(f) { return f.id === id || f.name === id; }); }, getValues: function(asString, dirtyOnly, includeEmptyText, useDataValues) { var values = {}, fields = this.getFields().items, grids = this.getGrids().items, // the grids found by the monitor f, fLen = fields.length, gLen = grids.length, // gridcount isArray = Ext.isArray, grid, gridData, gridStore, // some vars used while reading the grid content field, data, val, bucket, name; for (f = 0; f < fLen; f++) { field = fields[f]; if (!dirtyOnly || field.isDirty()) { data = field[useDataValues ? 'getModelData' : 'getSubmitData'](includeEmptyText); if (Ext.isObject(data)) { for (name in data) { if (data.hasOwnProperty(name)) { val = data[name]; if (includeEmptyText && val === '') { val = field.emptyText || ''; } if (values.hasOwnProperty(name)) { bucket = values[name]; if (!isArray(bucket)) { bucket = values[name] = [bucket]; } if (isArray(val)) { values[name] = bucket.concat(val); } else { bucket.push(val); } } else { values[name] = val; } } } } } } // begin new part for (g = 0; g < gLen; g++) { grid = grids[f]; gridStore = grid.getStore(); gridData = []; // You will need a identification variable to determine which data should be taken from the grid. Currently this demo implement three options // 0 only selected // 1 complete data within the store // 2 only modified records (this can be splitted to new and updated) var ditems = grid.submitData === 0 ? grid.getSelectionModel().getSelection() : grid.submitData === 1 ? gridStore.getStore().data.items : gridStore.getStore().getModifiedRecords(), dlen = ditems.length; for(d = 0; d < dLen; d++) { // push the model data to the current data list. It doesn't matter of which type the models (records) are, this will simply read the whole known data. Alternatively you may access the rawdata property if the reader does not know all fields. gridData.push(ditems[d].data); } // add the store data as array to the grid-name property data[grid.name] = gridData; } // end new part if (asString) { values = Ext.Object.toQueryString(values); } return values; } }); 

Next, change the form to use this basic form type.

 Ext.define('Ext.ux.form.Panel', { extend:'Ext.form.Panel', requires: ['Ext.ux.form.Basic'], /** * @private */ createForm: function() { var cfg = {}, props = this.basicFormConfigs, len = props.length, i = 0, prop; for (; i < len; ++i) { prop = props[i]; cfg[prop] = this[prop]; } return new Ext.ux.form.Basic(this, cfg); } }); 

Note:

This is all untested! I did something similar for different clients to expand the capabilities of forms, and I can say that this will work very well and quickly. At the very least, it should show how this can be done, and it can be easily customized to also set forms and / or upload / update entries.

+6
source

I have not used this myself, but there is a thread that tries to deal with related models through hasMany relationships. The problem is that everyone has slightly different expectations of what should happen while recording recordings. ORM Serever parties deal with this problem in a somewhat difficult to understand way and are often a sore spot for new developers.

Here's a forum thread that describes a custom JSON writer to save a parent post with a kids post.

Here is the code that seems to work, at least for some people:

 Ext.data.writer.Json.override({ /* * This function overrides the default implementation of json writer. Any hasMany relationships will be submitted * as nested objects. When preparing the data, only children which have been newly created, modified or marked for * deletion will be added. To do this, a depth first bottom -> up recursive technique was used. */ getRecordData: function(record) { //Setup variables var me = this, i, association, childStore, data = record.data; //Iterate over all the hasMany associations for (i = 0; i < record.associations.length; i++) { association = record.associations.get(i); data[association.name] = null; childStore = record[association.storeName]; //Iterate over all the children in the current association childStore.each(function(childRecord) { if (!data[association.name]){ data[association.name] = []; } //Recursively get the record data for children (depth first) var childData = this.getRecordData.call(this, childRecord); /* * If the child was marked dirty or phantom it must be added. If there was data returned that was neither * dirty or phantom, this means that the depth first recursion has detected that it has a child which is * either dirty or phantom. For this child to be put into the prepared data, it parents must be in place whether * they were modified or not. */ if (childRecord.dirty | childRecord.phantom | (childData != null)){ data[association.name].push(childData); record.setDirty(); } }, me); /* * Iterate over all the removed records and add them to the preparedData. Set a flag on them to show that * they are to be deleted */ Ext.each(childStore.removed, function(removedChildRecord) { //Set a flag here to identify removed records removedChildRecord.set('forDeletion', true); var removedChildData = this.getRecordData.call(this, removedChildRecord); data[association.name].push(removedChildData); record.setDirty(); }, me); } //Only return data if it was dirty, new or marked for deletion. if (record.dirty | record.phantom | record.get('forDeletion')){ return data; } } }); 

The full thread is here: http://www.sencha.com/forum/showthread.php?141957-Saving-objects-that-are-linked-hasMany-relation-with-a-single-Store/page5

0
source

Source: https://habr.com/ru/post/1490573/


All Articles