@neovici/cosmoz-omnitable 12.0.2 → 12.1.0

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.
@@ -9,7 +9,12 @@ import { html } from 'lit-html';
9
9
 
10
10
  const
11
11
  onChange = setState => event => setState(state => {
12
- clearInterval(state.t);
12
+ // skip the event emitted during paper-input initialization
13
+ if(state.inputValue === undefined && event.target.value === '') {
14
+ return state;
15
+ }
16
+
17
+ clearTimeout(state.t);
13
18
  const t = setTimeout(() => setState(state => ({ ...state, filter: state.inputValue })), 1000);
14
19
  return { ...state, inputValue: event.target.value, t };
15
20
  }),
@@ -76,7 +76,7 @@ const checkbox = css`
76
76
 
77
77
  export { checkbox };
78
78
 
79
- export default `<style>
79
+ export default `
80
80
  :host {
81
81
  display: flex;
82
82
  flex-direction: column;
@@ -435,4 +435,4 @@ export default `<style>
435
435
  .expand:hover, .fold:hover {
436
436
  color: #000;
437
437
  }
438
- </style>`;
438
+ `;
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines-per-function */
1
2
  /* eslint-disable max-lines */
2
3
  import '@polymer/iron-icons/iron-icons';
3
4
  import '@polymer/iron-icon/iron-icon';
@@ -15,545 +16,42 @@ import './cosmoz-omnitable-group-row';
15
16
  import './cosmoz-omnitable-columns';
16
17
  import styles from './cosmoz-omnitable-styles';
17
18
 
18
- import { PolymerElement } from '@polymer/polymer/polymer-element';
19
- import { html } from '@polymer/polymer/lib/utils/html-tag';
20
- import { html as litHtml } from 'lit-html';
19
+ import { html as polymerHtml } from '@polymer/polymer/lib/utils/html-tag';
20
+ import { html } from 'lit-html';
21
21
 
22
- import { translatable } from '@neovici/cosmoz-i18next';
23
- import { mixin, hauntedPolymer } from '@neovici/cosmoz-utils';
24
- import { isEmpty } from '@neovici/cosmoz-utils/template';
25
22
  import { useOmnitable } from './lib/use-omnitable';
26
- import { saveAsCsvAction } from './lib/save-as-csv-action';
27
- import { saveAsXlsxAction } from './lib/save-as-xlsx-action';
28
- import { defaultPlacement } from '@neovici/cosmoz-dropdown';
29
- import { indexSymbol } from './lib/utils';
30
-
31
- /**
32
- * @polymer
33
- * @customElement
34
- * @appliesMixin translatable
35
- * @group Cosmoz Elements
36
- * @element cosmoz-omnitable
37
- * @demo demo/index.html
38
- */
39
-
40
- class Omnitable extends hauntedPolymer(useOmnitable)(
41
- mixin({ isEmpty }, translatable(PolymerElement))
42
- ) {
43
- /* eslint-disable-next-line max-lines-per-function */
44
- static get template() {
45
- const template = html`
46
- ${html(Object.assign([styles], { raw: [styles] }))}
47
- <div id="layoutStyle"></div>
48
-
49
- <div class="mainContainer">
50
- <sort-and-group-provider value="[[ sortAndGroup ]]">
51
- <div class="header" id="header">
52
- <input
53
- class="checkbox all"
54
- type="checkbox"
55
- checked="[[ _allSelected ]]"
56
- on-input="_onAllCheckboxChange"
57
- disabled$="[[ !_dataIsValid ]]"
58
- />
59
- <cosmoz-omnitable-header-row
60
- data="[[ data ]]"
61
- columns="[[ columns ]]"
62
- filters="[[ filters ]]"
63
- group-on-column="[[ groupOnColumn ]]"
64
- set-filter-state="[[ setFilterState ]]"
65
- settings-config="[[ settingsConfig ]]"
66
- ></cosmoz-omnitable-header-row>
67
- </div>
68
- </sort-and-group-provider>
69
- <div class="tableContent" id="tableContent">
70
- <template is="dom-if" if="[[ !_dataIsValid ]]">
71
- <div class="tableContent-empty">
72
- <slot name="empty-set-message">
73
- <iron-icon icon="icons:announcement"></iron-icon>
74
- <div class="tableContent-empty-message">
75
- <h3>[[ _('Working set empty', t) ]]</h3>
76
- <p>[[ _('No data to display', t) ]]</p>
77
- </div>
78
- </slot>
79
- </div>
80
- </template>
81
- <template is="dom-if" if="[[ _filterIsTooStrict ]]">
82
- <div class="tableContent-empty">
83
- <iron-icon icon="icons:announcement"></iron-icon>
84
- <div>
85
- <h3>[[ _('Filter too strict', t) ]]</h3>
86
- <p>[[ _('No matches for selection', t) ]]</p>
87
- </div>
88
- </div>
89
- </template>
90
- <template is="dom-if" if="[[ loading ]]">
91
- <div class="tableContent-empty overlay">
92
- <paper-spinner-lite active="[[ loading ]]"></paper-spinner-lite>
93
- <div>
94
- <h3>[[ _('Data set is updating', t) ]]</h3>
95
- </div>
96
- </div>
97
- </template>
98
- <div class="tableContent-scroller" id="scroller">
99
- <cosmoz-grouped-list
100
- id="groupedList"
101
- data="{{ sortedFilteredGroupedItems }}"
102
- selected-items="{{ selectedItems }}"
103
- display-empty-groups="[[ displayEmptyGroups ]]"
104
- compare-items-fn="[[ compareItemsFn ]]"
105
- render-item="[[ renderItem(collapsedColumns) ]]"
106
- render-group="[[ renderGroup ]]"
107
- ></cosmoz-grouped-list>
108
- </div>
109
- </div>
110
- <cosmoz-bottom-bar
111
- id="bottomBar"
112
- on-action="_onAction"
113
- active$="[[ !isEmpty(selectedItems.length) ]]"
114
- >
115
- <slot name="info" slot="info"
116
- >[[ ngettext('{0} selected item', '{0} selected items',
117
- selectedItems.length, t) ]]</slot
118
- >
119
- <slot name="actions" id="actions"></slot>
120
- <!-- These slots are needed by cosmoz-bottom-bar
121
- as it might change the slot of the actions to distribute them in the menu -->
122
- <slot name="bottom-bar-toolbar" slot="bottom-bar-toolbar"></slot>
123
- <slot name="bottom-bar-menu" slot="bottom-bar-menu"></slot>
124
- <cosmoz-dropdown-menu slot="extra" placement="[[ topPlacement ]]">
125
- <svg
126
- slot="button"
127
- width="14"
128
- height="18"
129
- viewBox="0 0 14 18"
130
- fill="none"
131
- stroke="currentColor"
132
- xmlns="http://www.w3.org/2000/svg"
133
- >
134
- <path
135
- d="M1 8.5L7.00024 14.5L13 8.5"
136
- stroke-width="2"
137
- stroke-linecap="round"
138
- stroke-linejoin="round"
139
- />
140
- <path d="M13 17L1 17" stroke-width="2" stroke-linecap="round" />
141
- <path d="M7 1V13" stroke-width="2" stroke-linecap="round" />
142
- </svg>
143
- <button on-click="_saveAsCsvAction">
144
- [[ _('Save as CSV', t) ]]
145
- </button>
146
- <button on-click="_saveAsXlsxAction">
147
- [[ _('Save as XLSX', t) ]]
148
- </button>
149
- <slot name="download-menu"></slot>
150
- </cosmoz-dropdown-menu>
151
- </cosmoz-bottom-bar>
152
- </div>
153
-
154
- <div id="columns">
155
- <slot id="columnsSlot"></slot>
156
- </div>
157
- `;
158
- template.setAttribute('strip-whitespace', '');
159
- return template;
160
- }
161
-
162
- renderItem(collapsedColumns) {
163
- return (item, index, { selected, expanded, toggleCollapse }) => {
164
- return litHtml`
165
- <div class="item-row-wrapper">
166
- <div ?selected=${selected}
167
- part="itemRow itemRow-${item[indexSymbol]}"
168
- .dataIndex=${item[indexSymbol]}
169
- .dataItem=${item}
170
- class="itemRow"
171
- @click=${this.onItemClick}
172
- >
173
- <input class="checkbox"
174
- type="checkbox"
175
- .checked=${selected}
176
- .dataItem=${item}
177
- @input=${this._onCheckboxChange}
178
- ?disabled=${!this._dataIsValid} />
179
- <cosmoz-omnitable-item-row
180
- .columns=${this.columns}
181
- .index=${index}
182
- .selected=${selected}
183
- .expanded=${expanded}
184
- .item=${item}
185
- .groupOnColumn=${this.groupOnColumn}
186
- .onItemChange=${this.onItemChange}>
187
- </cosmoz-omnitable-item-row>
188
- <paper-icon-button
189
- class="expand"
190
- ?hidden=${isEmpty(collapsedColumns.length)}
191
- .icon=${this._getFoldIcon(expanded)}
192
- @click=${toggleCollapse}
193
- ></paper-icon-button>
194
- </div>
195
- <cosmoz-omnitable-item-expand .columns=${collapsedColumns}
196
- .item=${item}
197
- .index=${index}
198
- ?selected=${selected}
199
- ?expanded=${expanded}
200
- .groupOnColumn=${this.groupOnColumn}
201
- part="item-expand">
202
- </cosmoz-omnitable-item-expand>
203
- </div>`;
204
- };
205
- }
206
-
207
- renderGroup(item, index, { selected, folded, toggleFold }) {
208
- return litHtml`
209
- <div class="${this._getGroupRowClasses(folded)}"
210
- part="groupRow groupRow-${item[indexSymbol]}">
211
- <input class="checkbox"
212
- type="checkbox"
213
- .checked=${selected}
214
- .dataItem=${item}
215
- @input=${this._onCheckboxChange}
216
- ?disabled=${!this._dataIsValid} />
217
- <h3 class="groupRow-label">
218
- <div><span>${this.groupOnColumn?.title}</span>: &nbsp;</div>
219
- <cosmoz-omnitable-group-row
220
- .column=${this.groupOnColumn}
221
- .item=${item.items?.[0]}
222
- .selected=${selected}
223
- .folded=${folded}
224
- .group=${item}
225
- ></cosmoz-omnitable-group-row>
226
- </h3>
227
- <div class="groupRow-badge">${item.items.length}</div>
228
- <paper-icon-button
229
- class="fold"
230
- .icon=${this._getFoldIcon(folded)}
231
- @click=${toggleFold}></paper-icon-button>
232
- </div>`;
233
- }
234
-
235
- /* eslint-disable-next-line max-lines-per-function */
236
- static get properties() {
237
- return {
238
- /**
239
- * Filename when saving as CSV
240
- */
241
- csvFilename: { type: String, value: 'omnitable.csv' },
242
-
243
- /**
244
- * Filename when saving as XLSX
245
- */
246
- xlsxFilename: { type: String, value: 'omnitable.xlsx' },
247
-
248
- /**
249
- * Sheet name when saving as XLSX
250
- */
251
- xlsxSheetname: { type: String, value: 'Omnitable' },
252
-
253
- /**
254
- * Array used to list items.
255
- */
256
- data: { type: Array },
257
-
258
- /**
259
- * This function is used to determine which items are kept selected across data updates
260
- */
261
- compareItemsFn: Function,
262
-
263
- /**
264
- * True if data is a valid and not empty array.
265
- */
266
- _dataIsValid: {
267
- type: Boolean,
268
- value: false,
269
- computed: '_computeDataValidity(data.*)',
270
- },
271
-
272
- /**
273
- * If set to true, then group a row will be displayed for groups that contain no items.
274
- */
275
- displayEmptyGroups: { type: Boolean, value: false },
276
-
277
- /**
278
- * Specific columns to enable
279
- */
280
- enabledColumns: { type: Array, notify: true },
281
-
282
- /**
283
- * Whether bottom-bar has actions.
284
- */
285
- hasActions: { type: Boolean, value: false },
286
-
287
- /**
288
- * Shows a loading overlay to indicate data will be updated
289
- */
290
- loading: { type: Boolean, value: false },
291
-
292
- /**
293
- * List of selected rows/items in `data`.
294
- */
295
- selectedItems: { type: Array, notify: true, value: () => [] },
296
- descending: { type: Boolean, value: false, notify: true },
297
- sortOn: { type: String, value: '', notify: true },
298
- groupOnDescending: { type: Boolean, value: false },
299
-
300
- /**
301
- * The column name to group on.
302
- */
303
- groupOn: { type: String, notify: true, value: '' },
304
-
305
- /**
306
- * Sorted items structure after filtering and grouping.
307
- */
308
- sortedFilteredGroupedItems: { type: Array, notify: true },
309
-
310
- /**
311
- * List of columns definition for this table.
312
- */
313
- columns: { type: Array, notify: true, value: () => [] },
314
- settings: { type: Object, notify: true },
315
- _filterIsTooStrict: {
316
- type: Boolean,
317
- computed:
318
- '_computeFilterIsTooStrict(_dataIsValid, sortedFilteredGroupedItems.length)',
319
- },
320
- hashParam: { type: String },
321
-
322
- /**
323
- * True when all items are selected.
324
- */
325
- _allSelected: { type: Boolean },
326
- computedBarHeight: { type: Number },
327
- settingsId: { type: String, value: undefined },
328
- topPlacement: {
329
- value: ['top-right', ...defaultPlacement],
330
- },
331
- };
332
- }
333
-
334
- static get observers() {
335
- return ['_selectedItemsChanged(selectedItems.*)'];
336
- }
337
-
338
- constructor() {
339
- super();
340
-
341
- this._onKey = this._onKey.bind(this);
342
- this._onCheckboxChange = this._onCheckboxChange.bind(this);
343
- this.renderItem = this.renderItem.bind(this);
344
- this.renderGroup = this.renderGroup.bind(this);
345
- }
346
-
347
- connectedCallback() {
348
- super.connectedCallback();
349
-
350
- this.$.groupedList.scrollTarget = this.$.scroller;
351
-
352
- window.addEventListener('keydown', this._onKey);
353
- window.addEventListener('keyup', this._onKey);
354
- }
355
-
356
- disconnectedCallback() {
357
- super.disconnectedCallback();
358
-
359
- window.removeEventListener('keydown', this._onKey);
360
- window.removeEventListener('keyup', this._onKey);
361
- }
362
-
363
- /** ELEMENT BEHAVIOR */
364
-
365
- _computeDataValidity({ base: data } = {}) {
366
- return data && Array.isArray(data) && data.length > 0;
367
- }
368
-
369
- _computeFilterIsTooStrict(dataIsValid, visibleItemsLength) {
370
- return dataIsValid && visibleItemsLength < 1;
371
- }
372
-
373
- _onKey(e) {
374
- this._shiftKey = e.shiftKey;
375
- this._ctrlKey = e.ctrlKey;
376
- }
377
-
378
- _onCheckboxChange(event) {
379
- const item = event.target.dataItem,
380
- selected = event.target.checked;
381
- if (this._shiftKey) {
382
- this.$.groupedList.toggleSelectTo(item, selected);
383
- } else if (this._ctrlKey) {
384
- event.target.checked = true;
385
- this.$.groupedList.selectOnly(item);
386
- } else {
387
- this.$.groupedList.toggleSelect(item, selected);
388
- }
389
-
390
- event.preventDefault();
391
- event.stopPropagation();
392
- }
393
-
394
- /**
395
- * Triggers a download of selected rows as a CSV file.
396
- * @returns {undefined}
397
- */
398
- _saveAsCsvAction() {
399
- saveAsCsvAction(this.columns, this.selectedItems, this.csvFilename);
400
- }
401
-
402
- /**
403
- * Triggers a download of selected rows as a XLSX file.
404
- * @returns {undefined}
405
- */
406
- _saveAsXlsxAction() {
407
- saveAsXlsxAction(
408
- this.columns,
409
- this.selectedItems,
410
- this.xlsxFilename,
411
- this.xlsxSheetname
412
- );
413
- }
414
-
415
- /** view functions */
416
-
417
- _getGroupRowClasses(folded) {
418
- return folded ? 'groupRow groupRow-folded' : 'groupRow';
419
- }
420
- _getFoldIcon(expanded) {
421
- return expanded ? 'expand-less' : 'expand-more';
422
- }
423
-
424
- /**
425
- * Turn an `action` event into a `run` event
426
- * @param {Event} event `action` event
427
- * @param {Object} detail `action` event details
428
- * @returns {undefined}
429
- */
430
- _onAction(event, detail) {
431
- detail.item.dispatchEvent(
432
- new window.CustomEvent('run', {
433
- bubbles: true,
434
- cancelable: true,
435
- detail: {
436
- omnitable: this,
437
- items: this.selectedItems,
438
- },
439
- })
440
- );
441
- event.stopPropagation();
442
- }
443
-
444
- _selectedItemsChanged(change) {
445
- if (
446
- change.path === 'selectedItems' ||
447
- change.path === 'selectedItems.splices'
448
- ) {
449
- this._allSelected =
450
- this.data &&
451
- this.data.length > 0 &&
452
- change.base.length === this.data.length;
453
- }
454
- }
455
-
456
- _onAllCheckboxChange(event) {
457
- if (event.target.checked) {
458
- this.$.groupedList.selectAll();
459
- } else {
460
- this.$.groupedList.deselectAll();
461
- }
462
- }
463
-
464
- // TODO: move to publicInterface mixin
465
- /** PUBLIC */
466
- /**
467
- * Remove multiple items from `data`
468
- * @param {Array} items Array of items to remove
469
- * @return {Array} Array containing removed items
470
- */
471
- removeItems(items) {
472
- const removedItems = [];
473
-
474
- for (let i = items.length - 1; i >= 0; i -= 1) {
475
- const removed = this.removeItem(items[i]);
476
- if (removed != null) {
477
- removedItems.push(removed);
478
- }
479
- }
480
- return removedItems;
481
- }
482
- /**
483
- * Helper method to remove an item from `data`.
484
- * @param {Object} item Item to remove
485
- * @return {Object} item removed
486
- */
487
- removeItem(item) {
488
- const index = this.data.indexOf(item);
489
-
490
- if (index < 0) {
491
- return null;
492
- }
493
-
494
- const removed = this.splice('data', index, 1);
495
- this.data = this.data.slice();
496
- if (Array.isArray(removed) && removed.length > 0) {
497
- return removed[0];
498
- }
499
- }
500
- replaceItem(oldItem, newItem) {
501
- const itemIndex = this.data.indexOf(oldItem);
502
- if (itemIndex > -1) {
503
- return this.replaceItemAtIndex(itemIndex, newItem);
504
- }
505
- }
506
- replaceItemAtIndex(index, newItem) {
507
- this.splice('data', index, 1, newItem);
508
- this.data = this.data.slice();
509
- }
510
- /**
511
- * Convenience method for setting a value to an item's path and notifying any
512
- * element bound to this item's path.
513
- * @param {Object} item The item.
514
- * @param {itemPath} itemPath The path of the item.
515
- * @param {String} value The new value of the item.
516
- * @returns {void}
517
- */
518
- setItemValue(item, itemPath, value) {
519
- const key = this.data.indexOf(item);
520
-
521
- this.set('data.' + key + '.' + itemPath, value);
522
- }
523
-
524
- selectItem(item) {
525
- this.$.groupedList.select(item);
526
- }
527
-
528
- deselectItem(item) {
529
- this.$.groupedList.deselect(item);
530
- }
531
-
532
- isItemSelected(item) {
533
- return this.$.groupedList.isItemSelected(item);
534
- }
535
-
536
- onItemClick(e) {
537
- const composedPath = e.composedPath(),
538
- path = composedPath.slice(0, composedPath.indexOf(e.currentTarget));
539
-
540
- if (path.find((e) => e.matches?.('a, .checkbox, .expand'))) {
541
- return;
542
- }
543
-
544
- this.dispatchEvent(
545
- new window.CustomEvent('omnitable-item-click', {
546
- bubbles: true,
547
- composed: true,
548
- detail: {
549
- item: e.currentTarget.dataItem,
550
- index: e.currentTarget.dataIndex,
551
- },
552
- })
553
- );
554
- }
555
- }
556
- customElements.define('cosmoz-omnitable', Omnitable);
23
+ import { component } from 'haunted';
24
+ import { renderHeader } from './lib/render-header';
25
+ import { renderFooter } from './lib/render-footer';
26
+ import { renderList } from './lib/render-list';
27
+
28
+ const Omnitable = (host) => {
29
+ const { header, list, footer } = useOmnitable(host);
30
+
31
+ return html`
32
+ <style>
33
+ ${styles}
34
+ </style>
35
+ <div id="layoutStyle"></div>
36
+
37
+ <div class="mainContainer">
38
+ ${renderHeader(header)}
39
+ <div class="tableContent" id="tableContent">${renderList(list)}</div>
40
+ ${renderFooter(footer)}
41
+ </div>
42
+
43
+ <div id="columns">
44
+ <slot id="columnsSlot"></slot>
45
+ </div>
46
+ `;
47
+ };
48
+
49
+ customElements.define(
50
+ 'cosmoz-omnitable',
51
+ component(Omnitable, {
52
+ observedAttributes: ['hash-param', 'sort-on', 'group-on'],
53
+ })
54
+ );
557
55
 
558
56
  const tmplt = `
559
57
  <slot name="actions" slot="actions"></slot>
@@ -561,5 +59,5 @@ const tmplt = `
561
59
  <slot name="bottom-bar-menu" slot="bottom-bar-menu"></slot>
562
60
  `;
563
61
 
564
- export const actionSlots = litHtml(Object.assign([tmplt], { raw: [tmplt] })),
565
- actionSlotsPolymer = html(Object.assign([tmplt], { raw: [tmplt] }));
62
+ export const actionSlots = html(Object.assign([tmplt], { raw: [tmplt] })),
63
+ actionSlotsPolymer = polymerHtml(Object.assign([tmplt], { raw: [tmplt] }));
@@ -0,0 +1,71 @@
1
+ import { html } from 'lit-html';
2
+ import { ngettext, _ } from '@neovici/cosmoz-i18next';
3
+ import { saveAsCsvAction } from './save-as-csv-action';
4
+ import { saveAsXlsxAction } from './save-as-xlsx-action';
5
+ import { isEmpty } from '@neovici/cosmoz-utils/template';
6
+
7
+ // eslint-disable-next-line max-lines-per-function
8
+ export const renderFooter = ({
9
+ columns,
10
+ selectedItems,
11
+ csvFilename,
12
+ xlsxFilename,
13
+ xlsxSheetname,
14
+ topPlacement
15
+ }) => {
16
+ return html`
17
+ <cosmoz-bottom-bar
18
+ id="bottomBar"
19
+ ?active=${!isEmpty(selectedItems.length)}
20
+ >
21
+ <slot name="info" slot="info">
22
+ ${ngettext(
23
+ '{0} selected item',
24
+ '{0} selected items',
25
+ selectedItems.length
26
+ )}
27
+ </slot>
28
+ <slot name="actions" id="actions"></slot>
29
+ <!-- These slots are needed by cosmoz-bottom-bar
30
+ as it might change the slot of the actions to distribute them in the menu -->
31
+ <slot name="bottom-bar-toolbar" slot="bottom-bar-toolbar"></slot>
32
+ <slot name="bottom-bar-menu" slot="bottom-bar-menu"></slot>
33
+ <cosmoz-dropdown-menu slot="extra" placement=${topPlacement}>
34
+ <svg
35
+ slot="button"
36
+ width="14"
37
+ height="18"
38
+ viewBox="0 0 14 18"
39
+ fill="none"
40
+ stroke="currentColor"
41
+ xmlns="http://www.w3.org/2000/svg"
42
+ >
43
+ <path
44
+ d="M1 8.5L7.00024 14.5L13 8.5"
45
+ stroke-width="2"
46
+ stroke-linecap="round"
47
+ stroke-linejoin="round"
48
+ />
49
+ <path d="M13 17L1 17" stroke-width="2" stroke-linecap="round" />
50
+ <path d="M7 1V13" stroke-width="2" stroke-linecap="round" />
51
+ </svg>
52
+ <button
53
+ @click=${() => saveAsCsvAction(columns, selectedItems, csvFilename)}
54
+ >
55
+ ${_('Save as CSV')}
56
+ </button>
57
+ <button
58
+ @click=${() =>
59
+ saveAsXlsxAction(
60
+ columns,
61
+ selectedItems,
62
+ xlsxFilename,
63
+ xlsxSheetname
64
+ )}
65
+ >
66
+ ${_('Save as XLSX')}
67
+ </button>
68
+ <slot name="download-menu"></slot>
69
+ </cosmoz-dropdown-menu>
70
+ </cosmoz-bottom-bar>`;
71
+ };