Browse Source

Add more constants and convert params object to a class

John Supplee 4 năm trước cách đây
mục cha
commit
ba00311dd4

+ 51 - 43
client/components/main/globalSearch.js

@@ -1,9 +1,17 @@
 import { CardSearchPagedComponent } from '../../lib/cardSearch';
 import moment from 'moment';
 import {
+  OPERATOR_ASSIGNEE,
   OPERATOR_BOARD,
-  OPERATOR_DUE, OPERATOR_LIST,
-  OPERATOR_SWIMLANE, OPERATOR_USER,
+  OPERATOR_DUE,
+  OPERATOR_HAS,
+  OPERATOR_LABEL,
+  OPERATOR_LIST,
+  OPERATOR_MEMBER,
+  OPERATOR_SORT,
+  OPERATOR_STATUS,
+  OPERATOR_SWIMLANE,
+  OPERATOR_USER,
   ORDER_ASCENDING,
   ORDER_DESCENDING,
   PREDICATE_ALL,
@@ -28,6 +36,7 @@ import {
   PREDICATE_WEEK,
   PREDICATE_YEAR,
 } from '../../../config/search-const';
+import { QueryParams } from "../../../config/query-classes";
 
 // const subManager = new SubsManager();
 
@@ -169,21 +178,21 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
       'operator-swimlane-abbrev': OPERATOR_SWIMLANE,
       'operator-list': OPERATOR_LIST,
       'operator-list-abbrev': OPERATOR_LIST,
-      'operator-label': 'labels',
-      'operator-label-abbrev': 'labels',
+      'operator-label': OPERATOR_LABEL,
+      'operator-label-abbrev': OPERATOR_LABEL,
       'operator-user': OPERATOR_USER,
       'operator-user-abbrev': OPERATOR_USER,
-      'operator-member': 'members',
-      'operator-member-abbrev': 'members',
-      'operator-assignee': 'assignees',
-      'operator-assignee-abbrev': 'assignees',
-      'operator-status': 'status',
+      'operator-member': OPERATOR_MEMBER,
+      'operator-member-abbrev': OPERATOR_MEMBER,
+      'operator-assignee': OPERATOR_ASSIGNEE,
+      'operator-assignee-abbrev': OPERATOR_ASSIGNEE,
+      'operator-status': OPERATOR_STATUS,
       'operator-due': OPERATOR_DUE,
       'operator-created': 'createdAt',
       'operator-modified': 'modifiedAt',
       'operator-comment': 'comments',
-      'operator-has': 'has',
-      'operator-sort': 'sort',
+      'operator-has': OPERATOR_HAS,
+      'operator-sort': OPERATOR_SORT,
       'operator-limit': 'limit',
     };
 
@@ -238,28 +247,30 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
     // eslint-disable-next-line no-console
     // console.log('operatorMap:', operatorMap);
 
-    const params = {
-      limit: this.resultsPerPage,
-      // boards: [],
-      // swimlanes: [],
-      // lists: [],
-      // users: [],
-      members: [],
-      assignees: [],
-      labels: [],
-      status: [],
-      // dueAt: null,
-      createdAt: null,
-      modifiedAt: null,
-      comments: [],
-      has: [],
-    };
-    params[OPERATOR_BOARD] = [];
-    params[OPERATOR_DUE] = null;
-    params[OPERATOR_LIST] = [];
-    params[OPERATOR_SWIMLANE] = [];
-    params[OPERATOR_USER] = [];
-
+    // const params = {
+    //   limit: this.resultsPerPage,
+    //   // boards: [],
+    //   // swimlanes: [],
+    //   // lists: [],
+    //   // users: [],
+    //   members: [],
+    //   assignees: [],
+    //   // labels: [],
+    //   status: [],
+    //   // dueAt: null,
+    //   createdAt: null,
+    //   modifiedAt: null,
+    //   comments: [],
+    //   has: [],
+    // };
+    // params[OPERATOR_BOARD] = [];
+    // params[OPERATOR_DUE] = null;
+    // params[OPERATOR_LABEL] = [];
+    // params[OPERATOR_LIST] = [];
+    // params[OPERATOR_SWIMLANE] = [];
+    // params[OPERATOR_USER] = [];
+
+    const params = new QueryParams();
     let text = '';
     while (query) {
       let m = query.match(reOperator1);
@@ -282,7 +293,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
         if (operatorMap.hasOwnProperty(op)) {
           const operator = operatorMap[op];
           let value = m.groups.value;
-          if (operator === 'labels') {
+          if (operator === OPERATOR_LABEL) {
             if (value in this.colorMap) {
               value = this.colorMap[value];
               // console.log('found color:', value);
@@ -366,7 +377,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
                   .format(),
               };
             }
-          } else if (operator === 'sort') {
+          } else if (operator === OPERATOR_SORT) {
             let negated = false;
             const m = value.match(reNegatedOperator);
             if (m) {
@@ -384,7 +395,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
                 order: negated ? ORDER_DESCENDING : ORDER_ASCENDING,
               };
             }
-          } else if (operator === 'status') {
+          } else if (operator === OPERATOR_STATUS) {
             if (!predicateTranslations.status[value]) {
               this.parsingErrors.push({
                 tag: 'operator-status-invalid',
@@ -393,7 +404,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
             } else {
               value = predicateTranslations.status[value];
             }
-          } else if (operator === 'has') {
+          } else if (operator === OPERATOR_HAS) {
             let negated = false;
             const m = value.match(reNegatedOperator);
             if (m) {
@@ -422,11 +433,8 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
               value = limit;
             }
           }
-          if (Array.isArray(params[operator])) {
-            params[operator].push(value);
-          } else {
-            params[operator] = value;
-          }
+
+          params.addPredicate(operator, value);
         } else {
           this.parsingErrors.push({
             tag: 'operator-unknown-error',
@@ -467,7 +475,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
       return;
     }
 
-    this.runGlobalSearch(params);
+    this.runGlobalSearch(params.getParams());
   }
 
   searchInstructions() {

+ 5 - 4
client/components/main/myCards.js

@@ -1,4 +1,6 @@
 import { CardSearchPagedComponent } from '../../lib/cardSearch';
+import {QueryParams} from "../../../config/query-classes";
+import {OPERATOR_SORT, OPERATOR_USER} from "../../../config/search-const";
 
 // const subManager = new SubsManager();
 
@@ -48,10 +50,9 @@ class MyCardsComponent extends CardSearchPagedComponent {
   onCreated() {
     super.onCreated();
 
-    const queryParams = {
-      users: [Meteor.user().username],
-      sort: { name: 'dueAt', order: 'des' },
-    };
+    const queryParams = new QueryParams();
+    queryParams.addPredicate(OPERATOR_USER, Meteor.user().username);
+    queryParams.addPredicate(OPERATOR_SORT, { name: 'dueAt', order: 'des' });
 
     this.runGlobalSearch(queryParams);
     Meteor.subscribe('setting');

+ 120 - 0
config/query-classes.js

@@ -0,0 +1,120 @@
+import {
+  OPERATOR_ASSIGNEE,
+  OPERATOR_BOARD,
+  OPERATOR_COMMENT,
+  OPERATOR_LABEL,
+  OPERATOR_LIST,
+  OPERATOR_MEMBER,
+  OPERATOR_SWIMLANE,
+  OPERATOR_USER,
+} from './search-const';
+
+export class QueryParams {
+
+  text = '';
+
+  constructor(params = {}) {
+    this.params = params;
+  }
+
+  hasOperator(operator) {
+    return this.params[operator];
+  }
+
+  addPredicate(operator, predicate) {
+    if (!this.hasOperator(operator)) {
+      this.params[operator] = [];
+    }
+    this.params[operator].push(predicate);
+  }
+
+  setPredicate(operator, predicate) {
+    this.params[operator] = predicate;
+  }
+
+  getPredicate(operator) {
+    return this.params[operator][0];
+  }
+
+  getPredicates(operator) {
+    return this.params[operator];
+  }
+
+  getParams() {
+    return this.params;
+  }
+}
+
+export class QueryErrors {
+  constructor() {
+    this.errors = {};
+
+    this.colorMap = Boards.colorMap();
+  }
+
+  addError(operator, value) {
+    if (!this.errors[operator]) {
+      this.errors[operator] = [];
+    }
+    this.errors[operator].push(value)
+  }
+
+  hasErrors() {
+    return Object.entries(this.errors).length > 0;
+  }
+
+  errorMessages() {
+    const messages = [];
+
+    const operatorTags = {};
+    operatorTags[OPERATOR_BOARD] = 'board-title-not-found';
+    operatorTags[OPERATOR_SWIMLANE] = 'swimlane-title-not-found';
+    operatorTags[OPERATOR_LABEL] = label => {
+      if (Boards.labelColors().includes(label)) {
+        return {
+          tag: 'label-color-not-found',
+          value: label,
+          color: true,
+        };
+      } else {
+        return {
+          tag: 'label-not-found',
+          value: label,
+          color: false,
+        };
+      }
+    };
+    operatorTags[OPERATOR_LIST] = 'list-title-not-found';
+    operatorTags[OPERATOR_COMMENT] = 'comment-not-found';
+    operatorTags[OPERATOR_USER] = 'user-username-not-found';
+    operatorTags[OPERATOR_ASSIGNEE] = 'user-username-not-found';
+    operatorTags[OPERATOR_MEMBER] = 'user-username-not-found';
+
+    Object.entries(this.errors, ([operator, value]) => {
+      if (typeof operatorTags[operator] === 'function') {
+        messages.push(operatorTags[operator](value));
+      } else {
+        messages.push({ tag: operatorTags[operator], value: value });
+      }
+    });
+
+    return messages;
+  }
+}
+
+export class Query {
+  params = {};
+  selector = {};
+  projection = {};
+  errors = new QueryErrors();
+
+  constructor(selector, projection) {
+    if (selector) {
+      this.selector = selector;
+    }
+
+    if (projection) {
+      this.projection = projection;
+    }
+  }
+}

+ 6 - 0
config/search-const.js

@@ -1,7 +1,13 @@
+export const OPERATOR_ASSIGNEE = 'assignee';
+export const OPERATOR_COMMENT = 'comment';
 export const OPERATOR_DUE = 'dueAt';
 export const OPERATOR_BOARD = 'board';
+export const OPERATOR_HAS = 'has';
 export const OPERATOR_LABEL = 'label';
 export const OPERATOR_LIST = 'list';
+export const OPERATOR_MEMBER = 'member';
+export const OPERATOR_SORT = 'sort';
+export const OPERATOR_STATUS = 'status';
 export const OPERATOR_SWIMLANE = 'swimlane';
 export const OPERATOR_USER = 'user';
 export const ORDER_ASCENDING = 'asc';

+ 67 - 149
server/publications/cards.js

@@ -9,8 +9,18 @@ import ChecklistItems from '../../models/checklistItems';
 import SessionData from '../../models/usersessiondata';
 import CustomFields from '../../models/customFields';
 import {
+  OPERATOR_ASSIGNEE,
   OPERATOR_BOARD,
-  OPERATOR_DUE, OPERATOR_LIST, OPERATOR_SWIMLANE, OPERATOR_USER,
+  OPERATOR_COMMENT,
+  OPERATOR_DUE,
+  OPERATOR_HAS,
+  OPERATOR_LABEL,
+  OPERATOR_LIST,
+  OPERATOR_MEMBER,
+  OPERATOR_SORT,
+  OPERATOR_STATUS,
+  OPERATOR_SWIMLANE,
+  OPERATOR_USER,
   ORDER_ASCENDING,
   PREDICATE_ALL,
   PREDICATE_ARCHIVED,
@@ -29,6 +39,7 @@ import {
   PREDICATE_START_AT,
   PREDICATE_SYSTEM,
 } from '../../config/search-const';
+import { QueryErrors, QueryParams, Query } from '../../config/query-classes';
 
 const escapeForRegex = require('escape-string-regexp');
 
@@ -38,8 +49,8 @@ Meteor.publish('card', cardId => {
 });
 
 Meteor.publish('myCards', function(sessionId) {
-  const queryParams = {}
-  queryParams[OPERATOR_USER] = [Meteor.user().username];
+  const queryParams = new QueryParams();
+  queryParams.addPredicate(OPERATOR_USER, Meteor.user().username);
 
   return findCards(sessionId, buildQuery(queryParams));
 });
@@ -65,109 +76,16 @@ Meteor.publish('myCards', function(sessionId) {
 //   return buildQuery(sessionId, queryParams);
 // });
 
-Meteor.publish('globalSearch', function(sessionId, queryParams) {
+Meteor.publish('globalSearch', function(sessionId, params) {
   check(sessionId, String);
-  check(queryParams, Object);
+  check(params, Object);
 
   // eslint-disable-next-line no-console
   // console.log('queryParams:', queryParams);
 
-  return findCards(sessionId, buildQuery(queryParams));
+  return findCards(sessionId, buildQuery(new QueryParams(params)));
 });
 
-class QueryErrors {
-  constructor() {
-    this.notFound = {
-      // boards: [],
-      // swimlanes: [],
-      // lists: [],
-      labels: [],
-      // users: [],
-      members: [],
-      assignees: [],
-      status: [],
-      comments: [],
-    };
-    this.notFound[OPERATOR_BOARD] = [];
-    this.notFound[OPERATOR_LIST] = [];
-    this.notFound[OPERATOR_SWIMLANE] = [];
-    this.notFound[OPERATOR_USER] = [];
-
-    this.colorMap = Boards.colorMap();
-  }
-
-  hasErrors() {
-    for (const value of Object.values(this.notFound)) {
-      if (value.length) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  errorMessages() {
-    const messages = [];
-
-    this.notFound[OPERATOR_BOARD].forEach(board => {
-      messages.push({ tag: 'board-title-not-found', value: board });
-    });
-    this.notFound[OPERATOR_SWIMLANE].forEach(swim => {
-      messages.push({ tag: 'swimlane-title-not-found', value: swim });
-    });
-    this.notFound[OPERATOR_LIST].forEach(list => {
-      messages.push({ tag: 'list-title-not-found', value: list });
-    });
-    this.notFound.comments.forEach(comments => {
-      comments.forEach(text => {
-        messages.push({ tag: 'comment-not-found', value: text });
-      });
-    });
-    this.notFound.labels.forEach(label => {
-      if (Boards.labelColors().includes(label)) {
-        messages.push({
-          tag: 'label-color-not-found',
-          value: label,
-          color: true,
-        });
-      } else {
-        messages.push({
-          tag: 'label-not-found',
-          value: label,
-          color: false,
-        });
-      }
-    });
-    this.notFound[OPERATOR_USER].forEach(user => {
-      messages.push({ tag: 'user-username-not-found', value: user });
-    });
-    this.notFound.members.forEach(user => {
-      messages.push({ tag: 'user-username-not-found', value: user });
-    });
-    this.notFound.assignees.forEach(user => {
-      messages.push({ tag: 'user-username-not-found', value: user });
-    });
-
-    return messages;
-  }
-}
-
-class Query {
-  params = {};
-  selector = {};
-  projection = {};
-  errors = new QueryErrors();
-
-  constructor(selector, projection) {
-    if (selector) {
-      this.selector = selector;
-    }
-
-    if (projection) {
-      this.projection = projection;
-    }
-  }
-}
-
 function buildSelector(queryParams) {
   const userId = Meteor.userId();
 
@@ -182,8 +100,8 @@ function buildSelector(queryParams) {
 
     let archived = false;
     let endAt = null;
-    if (queryParams.status && queryParams.status.length) {
-      queryParams.status.forEach(status => {
+    if (queryParams.hasOperator(OPERATOR_STATUS)) {
+      queryParams.getPredicates(OPERATOR_STATUS).forEach(status => {
         if (status === PREDICATE_ARCHIVED) {
           archived = true;
         } else if (status === PREDICATE_ALL) {
@@ -235,9 +153,9 @@ function buildSelector(queryParams) {
       selector.endAt = endAt;
     }
 
-    if (queryParams[OPERATOR_BOARD] && queryParams[OPERATOR_BOARD].length) {
+    if (queryParams.hasOperator(OPERATOR_BOARD)) {
       const queryBoards = [];
-      queryParams[OPERATOR_BOARD].forEach(query => {
+      queryParams.hasOperator(OPERATOR_BOARD).forEach(query => {
         const boards = Boards.userSearch(userId, {
           title: new RegExp(escapeForRegex(query), 'i'),
         });
@@ -246,19 +164,16 @@ function buildSelector(queryParams) {
             queryBoards.push(board._id);
           });
         } else {
-          errors.notFound[OPERATOR_BOARD].push(query);
+          errors.addError(OPERATOR_BOARD, query);
         }
       });
 
       selector.boardId.$in = queryBoards;
     }
 
-    if (
-      queryParams[OPERATOR_SWIMLANE] &&
-      queryParams[OPERATOR_SWIMLANE].length
-    ) {
+    if (queryParams.hasOperator(OPERATOR_SWIMLANE)) {
       const querySwimlanes = [];
-      queryParams[OPERATOR_SWIMLANE].forEach(query => {
+      queryParams.getPredicates(OPERATOR_SWIMLANE).forEach(query => {
         const swimlanes = Swimlanes.find({
           title: new RegExp(escapeForRegex(query), 'i'),
         });
@@ -267,7 +182,7 @@ function buildSelector(queryParams) {
             querySwimlanes.push(swim._id);
           });
         } else {
-          errors.notFound[OPERATOR_SWIMLANE].push(query);
+          errors.addError(OPERATOR_SWIMLANE, query);
         }
       });
 
@@ -278,9 +193,9 @@ function buildSelector(queryParams) {
       selector.swimlaneId.$in = querySwimlanes;
     }
 
-    if (queryParams[OPERATOR_LIST] && queryParams[OPERATOR_LIST].length) {
+    if (queryParams.hasOperator(OPERATOR_LIST)) {
       const queryLists = [];
-      queryParams[OPERATOR_LIST].forEach(query => {
+      queryParams.getPredicates(OPERATOR_LIST).forEach(query => {
         const lists = Lists.find({
           title: new RegExp(escapeForRegex(query), 'i'),
         });
@@ -289,7 +204,7 @@ function buildSelector(queryParams) {
             queryLists.push(list._id);
           });
         } else {
-          errors.notFound[OPERATOR_LIST].push(query);
+          errors.addError(OPERATOR_LIST, query);
         }
       });
 
@@ -300,8 +215,8 @@ function buildSelector(queryParams) {
       selector.listId.$in = queryLists;
     }
 
-    if (queryParams.comments && queryParams.comments.length) {
-      const cardIds = CardComments.textSearch(userId, queryParams.comments).map(
+    if (queryParams.hasOperator(OPERATOR_COMMENT)) {
+      const cardIds = CardComments.textSearch(userId, queryParams.getPredicates(OPERATOR_COMMENT)).map(
         com => {
           return com.cardId;
         },
@@ -309,42 +224,43 @@ function buildSelector(queryParams) {
       if (cardIds.length) {
         selector._id = { $in: cardIds };
       } else {
-        errors.notFound.comments.push(queryParams.comments);
+        queryParams.getPredicates(OPERATOR_COMMENT).forEach(comment => {
+          errors.addError(OPERATOR_COMMENT, comment);
+        });
       }
     }
 
     [OPERATOR_DUE, 'createdAt', 'modifiedAt'].forEach(field => {
-      if (queryParams[field]) {
+      if (queryParams.hasOperator(field)) {
         selector[field] = {};
-        selector[field][queryParams[field].operator] = new Date(
-          queryParams[field].value,
-        );
+        const predicate = queryParams.getPredicate(field);
+        selector[field][predicate.operator] = new Date(predicate.value);
       }
     });
 
-    const queryUsers = {
-      members: [],
-      assignees: [],
-    };
-    if (queryParams[OPERATOR_USER] && queryParams[OPERATOR_USER].length) {
-      queryParams[OPERATOR_USER].forEach(query => {
+    const queryUsers = {}
+    queryUsers[OPERATOR_ASSIGNEE] = [];
+    queryUsers[OPERATOR_MEMBER] = [];
+
+    if (queryParams.hasOperator(OPERATOR_USER)) {
+      queryParams.getPredicates(OPERATOR_USER).forEach(query => {
         const users = Users.find({
           username: query,
         });
         if (users.count()) {
           users.forEach(user => {
-            queryUsers.members.push(user._id);
-            queryUsers.assignees.push(user._id);
+            queryUsers[OPERATOR_MEMBER].push(user._id);
+            queryUsers[OPERATOR_ASSIGNEE].push(user._id);
           });
         } else {
-          errors.notFound[OPERATOR_USER].push(query);
+          errors.addError(OPERATOR_USER, query);
         }
       });
     }
 
-    ['members', 'assignees'].forEach(key => {
-      if (queryParams[key] && queryParams[key].length) {
-        queryParams[key].forEach(query => {
+    [OPERATOR_MEMBER, OPERATOR_ASSIGNEE].forEach(key => {
+      if (queryParams.hasOperator(key)) {
+        queryParams.getPredicates(key).forEach(query => {
           const users = Users.find({
             username: query,
           });
@@ -353,27 +269,27 @@ function buildSelector(queryParams) {
               queryUsers[key].push(user._id);
             });
           } else {
-            errors.notFound[key].push(query);
+            errors.addError(key, query);
           }
         });
       }
     });
 
-    if (queryUsers.members.length && queryUsers.assignees.length) {
+    if (queryUsers[OPERATOR_MEMBER].length && queryUsers[OPERATOR_ASSIGNEE].length) {
       selector.$and.push({
         $or: [
-          { members: { $in: queryUsers.members } },
-          { assignees: { $in: queryUsers.assignees } },
+          { members: { $in: queryUsers[OPERATOR_MEMBER] } },
+          { assignees: { $in: queryUsers[OPERATOR_ASSIGNEE] } },
         ],
       });
-    } else if (queryUsers.members.length) {
-      selector.members = { $in: queryUsers.members };
-    } else if (queryUsers.assignees.length) {
-      selector.assignees = { $in: queryUsers.assignees };
+    } else if (queryUsers[OPERATOR_MEMBER].length) {
+      selector.members = { $in: queryUsers[OPERATOR_MEMBER] };
+    } else if (queryUsers[OPERATOR_ASSIGNEE].length) {
+      selector.assignees = { $in: queryUsers[OPERATOR_ASSIGNEE] };
     }
 
-    if (queryParams.labels && queryParams.labels.length) {
-      queryParams.labels.forEach(label => {
+    if (queryParams.hasOperator(OPERATOR_LABEL)) {
+      queryParams.getPredicates(OPERATOR_LABEL).forEach(label => {
         const queryLabels = [];
 
         let boards = Boards.userSearch(userId, {
@@ -418,7 +334,7 @@ function buildSelector(queryParams) {
                 });
             });
           } else {
-            errors.notFound.labels.push(label);
+            errors.addError(OPERATOR_LABEL, label);
           }
         }
 
@@ -426,8 +342,8 @@ function buildSelector(queryParams) {
       });
     }
 
-    if (queryParams.has && queryParams.has.length) {
-      queryParams.has.forEach(has => {
+    if (queryParams.hasOperator(OPERATOR_HAS)) {
+      queryParams.getPredicates(OPERATOR_HAS).forEach(has => {
         switch (has.field) {
           case PREDICATE_ATTACHMENT:
             selector.$and.push({
@@ -569,9 +485,9 @@ function buildProjection(query) {
     limit,
   };
 
-  if (query.params.sort) {
-    const order = query.params.sort.order === ORDER_ASCENDING ? 1 : -1;
-    switch (query.params.sort.name) {
+  if (query.params[OPERATOR_SORT]) {
+    const order = query.params[OPERATOR_SORT].order === ORDER_ASCENDING ? 1 : -1;
+    switch (query.params[OPERATOR_SORT].name) {
       case PREDICATE_DUE_AT:
         projection.sort = {
           dueAt: order,
@@ -628,7 +544,9 @@ function buildQuery(queryParams) {
 Meteor.publish('brokenCards', function(sessionId) {
   check(sessionId, String);
 
-  const query = buildQuery({ status: [PREDICATE_ALL] });
+  const params = new QueryParams();
+  params.addPredicate(OPERATOR_STATUS, PREDICATE_ALL);
+  const query = buildQuery(params);
   query.selector.$or = [
     { boardId: { $in: [null, ''] } },
     { swimlaneId: { $in: [null, ''] } },