0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-17 23:44:39 -05:00

Added "Name" members filter (#2289)

refs https://github.com/TryGhost/Team/issues/1408

- switched to `@tryghost/nql` packages to get access to latest releases
- updated `GET /members` mirage endpoint with a try/catch and explicit logging to make any errors from NQL more visible
- added "Name" filter option
  - has `is`, `contains`, `does not contain`, `starts with`, `ends with` operators
  - uses a plain text field for the input value
- added support for `~`, `-~`, `~^`, and `~$` operators when generating NQL queries from filter definitions
This commit is contained in:
Kevin Ansfield 2022-03-08 21:30:20 +00:00 committed by GitHub
parent 59fb35592d
commit f65437b14c
6 changed files with 278 additions and 77 deletions

View file

@ -1,4 +1,16 @@
{{#if (eq @filter.type 'label')}} {{#if (eq @filter.type 'name')}}
<input
type="text"
value={{@filter.value}}
class="gh-input"
aria-label="Name filter"
{{on "input" (fn this.setInputFilterValue @filter)}}
{{on "blur" (fn this.updateInputFilterValue @filter)}}
{{on "keypress" (fn this.updateInputFilterValueOnEnter @filter)}}
data-test-input="members-filter-value"
/>
{{else if (eq @filter.type 'label')}}
<GhMemberLabelInput <GhMemberLabelInput
@onChange={{fn this.setLabelsFilterValue @filter}} @onChange={{fn this.setLabelsFilterValue @filter}}
@onLabelEdit={{@onLabelEdit}} @onLabelEdit={{@onLabelEdit}}

View file

@ -1,6 +1,6 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import moment from 'moment'; import moment from 'moment';
import nql from '@nexes/nql-lang'; import nql from '@tryghost/nql-lang';
import {TrackedArray} from 'tracked-built-ins'; import {TrackedArray} from 'tracked-built-ins';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
@ -9,7 +9,7 @@ import {tracked} from '@glimmer/tracking';
const FILTER_PROPERTIES = [ const FILTER_PROPERTIES = [
// Basic // Basic
// {label: 'Name', name: 'name', group: 'Basic'}, {label: 'Name', name: 'name', group: 'Basic', valueType: 'text', feature: 'membersContainsFilters'},
// {label: 'Email', name: 'email', group: 'Basic'}, // {label: 'Email', name: 'email', group: 'Basic'},
// {label: 'Location', name: 'location', group: 'Basic'}, // {label: 'Location', name: 'location', group: 'Basic'},
{label: 'Label', name: 'label', group: 'Basic', valueType: 'array'}, {label: 'Label', name: 'label', group: 'Basic', valueType: 'array'},
@ -60,7 +60,13 @@ const NUMBER_RELATION_OPTIONS = [
]; ];
const FILTER_RELATIONS_OPTIONS = { const FILTER_RELATIONS_OPTIONS = {
// name: MATCH_RELATION_OPTIONS, name: [
{label: 'is', name: 'is'},
{label: 'contains', name: 'contains'},
{label: 'does not contain', name: 'does-not-contain'},
{label: 'starts with', name: 'starts-with'},
{label: 'ends with', name: 'ends-with'}
],
// email: MATCH_RELATION_OPTIONS, // email: MATCH_RELATION_OPTIONS,
label: MATCH_RELATION_OPTIONS, label: MATCH_RELATION_OPTIONS,
product: MATCH_RELATION_OPTIONS, product: MATCH_RELATION_OPTIONS,
@ -212,6 +218,9 @@ export default class MembersFilter extends Component {
if (filterProperty.valueType === 'array' && filter.value?.length) { if (filterProperty.valueType === 'array' && filter.value?.length) {
const filterValue = '[' + filter.value.join(',') + ']'; const filterValue = '[' + filter.value.join(',') + ']';
query += `${filter.type}:${relationStr}${filterValue}+`; query += `${filter.type}:${relationStr}${filterValue}+`;
} else if (filterProperty.valueType === 'text') {
const filterValue = '\'' + filter.value.replace(/'/g, '\\\'') + '\'';
query += `${filter.type}:${relationStr}${filterValue}+`;
} else if (filterProperty.valueType === 'date') { } else if (filterProperty.valueType === 'date') {
let filterValue; let filterValue;
@ -285,6 +294,30 @@ export default class MembersFilter extends Component {
relation = 'is-or-less'; relation = 'is-or-less';
value = nqlValue.$lte; value = nqlValue.$lte;
} }
if (nqlValue.$regex !== undefined) {
const source = nqlValue.$regex.source;
if (source.indexOf('^') === 0) {
relation = 'starts-with';
value = source.substring(1);
} else if (source.indexOf('$') === source.length - 1) {
relation = 'ends-with';
value = source.slice(0, -1);
} else {
relation = 'contains';
value = source;
}
value = value.replace(/\\/g, '');
}
if (nqlValue.$not !== undefined) {
relation = 'does-not-contain';
value = nqlValue.$not.source;
value = value.replace(/\\/g, '');
}
} else { } else {
relation = 'is'; relation = 'is';
value = nqlValue; value = nqlValue;
@ -337,7 +370,11 @@ export default class MembersFilter extends Component {
is: '', is: '',
'is-not': '-', 'is-not': '-',
'is-greater': '>', 'is-greater': '>',
'is-or-greater': '>=' 'is-or-greater': '>=',
contains: '~',
'does-not-contain': '-~',
'starts-with': '~^',
'ends-with': '~$'
}; };
return relationMap[relation] || ''; return relationMap[relation] || '';

View file

@ -1,6 +1,6 @@
import faker from 'faker'; import faker from 'faker';
import moment from 'moment'; import moment from 'moment';
import nql from '@nexes/nql'; import nql from '@tryghost/nql';
import {Response} from 'miragejs'; import {Response} from 'miragejs';
import {extractFilterParam, paginateModelCollection} from '../utils'; import {extractFilterParam, paginateModelCollection} from '../utils';
import {underscore} from '@ember/string'; import {underscore} from '@ember/string';
@ -73,50 +73,55 @@ export default function mockMembers(server) {
let collection = members.all(); let collection = members.all();
if (filter) { if (filter) {
const nqlFilter = nql(filter, { try {
expansions: [ const nqlFilter = nql(filter, {
{ expansions: [
key: 'label', {
replacement: 'labels.slug' key: 'label',
}, replacement: 'labels.slug'
{ },
key: 'product', {
replacement: 'products.slug' key: 'product',
} replacement: 'products.slug'
] }
}); ]
collection = collection.filter((member) => {
const serializedMember = {};
// mirage model keys match our main model keys so we need to transform
// camelCase to underscore to match the filter format
Object.keys(member.attrs).forEach((key) => {
serializedMember[underscore(key)] = member.attrs[key];
}); });
// similar deal for associated label models collection = collection.filter((member) => {
serializedMember.labels = []; const serializedMember = {};
member.labels.models.forEach((label) => {
const serializedLabel = {}; // mirage model keys match our main model keys so we need to transform
Object.keys(label.attrs).forEach((key) => { // camelCase to underscore to match the filter format
serializedLabel[underscore(key)] = label.attrs[key]; Object.keys(member.attrs).forEach((key) => {
serializedMember[underscore(key)] = member.attrs[key];
}); });
serializedMember.labels.push(serializedLabel);
});
// similar deal for associated product models // similar deal for associated label models
serializedMember.products = []; serializedMember.labels = [];
member.products.models.forEach((product) => { member.labels.models.forEach((label) => {
const serializedProduct = {}; const serializedLabel = {};
Object.keys(product.attrs).forEach((key) => { Object.keys(label.attrs).forEach((key) => {
serializedProduct[underscore(key)] = product.attrs[key]; serializedLabel[underscore(key)] = label.attrs[key];
});
serializedMember.labels.push(serializedLabel);
}); });
serializedMember.products.push(serializedProduct);
});
return nqlFilter.queryJSON(serializedMember); // similar deal for associated product models
}); serializedMember.products = [];
member.products.models.forEach((product) => {
const serializedProduct = {};
Object.keys(product.attrs).forEach((key) => {
serializedProduct[underscore(key)] = product.attrs[key];
});
serializedMember.products.push(serializedProduct);
});
return nqlFilter.queryJSON(serializedMember);
});
} catch (err) {
console.error(err); // eslint-disable-line
throw err;
}
} }
if (search) { if (search) {

View file

@ -36,8 +36,6 @@
"@glimmer/component": "1.0.4", "@glimmer/component": "1.0.4",
"@html-next/vertical-collection": "2.1.0", "@html-next/vertical-collection": "2.1.0",
"@joeattardi/emoji-button": "4.6.2", "@joeattardi/emoji-button": "4.6.2",
"@nexes/nql": "0.6.0",
"@nexes/nql-lang": "0.0.1",
"@sentry/ember": "6.16.1", "@sentry/ember": "6.16.1",
"@tryghost/color-utils": "0.1.9", "@tryghost/color-utils": "0.1.9",
"@tryghost/helpers": "1.1.59", "@tryghost/helpers": "1.1.59",
@ -46,6 +44,8 @@
"@tryghost/limit-service": "1.0.10", "@tryghost/limit-service": "1.0.10",
"@tryghost/members-csv": "1.2.6", "@tryghost/members-csv": "1.2.6",
"@tryghost/mobiledoc-kit": "0.12.5-ghost.1", "@tryghost/mobiledoc-kit": "0.12.5-ghost.1",
"@tryghost/nql": "0.9.0",
"@tryghost/nql-lang": "0.3.0",
"@tryghost/string": "0.1.23", "@tryghost/string": "0.1.23",
"@tryghost/timezone-data": "0.2.58", "@tryghost/timezone-data": "0.2.58",
"autoprefixer": "9.8.6", "autoprefixer": "9.8.6",

View file

@ -19,6 +19,7 @@ describe('Acceptance: Members filtering', function () {
beforeEach(async function () { beforeEach(async function () {
this.server.loadFixtures('configs'); this.server.loadFixtures('configs');
this.server.loadFixtures('settings'); this.server.loadFixtures('settings');
enableLabsFlag(this.server, 'membersContainsFilters');
enableLabsFlag(this.server, 'multipleProducts'); enableLabsFlag(this.server, 'multipleProducts');
// test with stripe connected and email turned on // test with stripe connected and email turned on
@ -833,6 +834,152 @@ describe('Acceptance: Members filtering', function () {
expect(find('[data-test-table-data="subscriptions.start_date"]')).to.contain.text('a month ago'); expect(find('[data-test-table-data="subscriptions.start_date"]')).to.contain.text('a month ago');
}); });
it('can filter by name', async function () {
this.server.create('member', {name: 'test-1'});
this.server.create('member', {name: 'test-2'});
this.server.create('member', {name: 'tset-1'});
this.server.create('member', {name: 'tset-2'});
this.server.create('member', {name: 'tset-3'});
this.server.create('member', {name: 'hello'});
this.server.create('member', {name: 'John O\'Nolan'});
this.server.create('member', {name: null});
await visit('/members');
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
.to.equal(8);
await click('[data-test-button="members-filter-actions"]');
const filterSelect = `[data-test-members-filter="0"]`;
const typeSelect = `${filterSelect} [data-test-select="members-filter"]`;
const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`;
const valueInput = `${filterSelect} [data-test-input="members-filter-value"]`;
expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="name"]`), 'name filter option').to.exist;
await fillIn(typeSelect, 'name');
// has the right operators
const operatorOptions = findAll(`${operatorSelect} option`);
expect(operatorOptions).to.have.length(5);
expect(operatorOptions[0]).to.have.value('is');
expect(operatorOptions[1]).to.have.value('contains');
expect(operatorOptions[2]).to.have.value('does-not-contain');
expect(operatorOptions[3]).to.have.value('starts-with');
expect(operatorOptions[4]).to.have.value('ends-with');
// has expected default operator and value
expect(find(operatorSelect)).to.have.value('is');
expect(find(valueInput)).to.have.value('');
// can change filter
await fillIn(valueInput, 'hello');
await blur(valueInput);
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is "hello"')
.to.equal(1);
// can change operator
await fillIn(operatorSelect, 'contains');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "hello"')
.to.equal(1);
// contains query works
await fillIn(valueInput, 'test');
await blur(valueInput);
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "test"')
.to.equal(2);
// starts with query works
await fillIn(operatorSelect, 'starts-with');
await fillIn(valueInput, 'tset');
await blur(valueInput);
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - starts with "tset"')
.to.equal(3);
// ends with query works
await fillIn(operatorSelect, 'ends-with');
await fillIn(valueInput, '2');
await blur(valueInput);
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - ends with "2"')
.to.equal(2);
// does not contain query works
await fillIn(operatorSelect, 'does-not-contain');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - does not contain "2"')
.to.equal(6);
// can query with escaped chars
await fillIn(operatorSelect, 'contains');
await fillIn(valueInput, `O'Nolan`);
await blur(valueInput);
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "O\'Nolan"')
.to.equal(1);
// no duplicate column added (name is included in the "details" column)
expect(find('[data-test-table-column="name"]')).to.not.exist;
// can handle contains operator in URL
let filter = encodeURIComponent(`name:~'hello'`);
await visit('/');
await visit(`/members?filter=${filter}`);
await click('[data-test-button="members-filter-actions"]');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "hello"')
.to.equal(1);
expect(find(operatorSelect)).to.have.value('contains');
expect(find(valueInput)).to.have.value('hello');
// can handle starts-with operator in URL
filter = encodeURIComponent(`name:~^'tset'`);
await visit('/');
await visit(`/members?filter=${filter}`);
await click('[data-test-button="members-filter-actions"]');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL starts with "tset"')
.to.equal(3);
expect(find(operatorSelect)).to.have.value('starts-with');
expect(find(valueInput)).to.have.value('tset');
// can handle ends-with operator in URL
filter = encodeURIComponent(`name:~$'2'`);
await visit('/');
await visit(`/members?filter=${filter}`);
await click('[data-test-button="members-filter-actions"]');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL ends with "2"')
.to.equal(2);
expect(find(operatorSelect)).to.have.value('ends-with');
expect(find(valueInput)).to.have.value('2');
// can handle does-not-contain operator in URL
filter = encodeURIComponent(`name:-~'2'`);
await visit('/');
await visit(`/members?filter=${filter}`);
await click('[data-test-button="members-filter-actions"]');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL does not contain "2"')
.to.equal(6);
expect(find(operatorSelect)).to.have.value('does-not-contain');
expect(find(valueInput)).to.have.value('2');
// can handle escaped values in URL
filter = encodeURIComponent(`name:~'O\\'Nolan'`);
await visit('/');
await visit(`/members?filter=${filter}`);
await click('[data-test-button="members-filter-actions"]');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "O\'Nolan"')
.to.equal(1);
expect(find(operatorSelect)).to.have.value('contains');
expect(find(valueInput)).to.have.value(`O'Nolan`);
// can handle regex special chars in URL
filter = encodeURIComponent(`name:~'test+test'`);
await visit('/');
await visit(`/members?filter=${filter}`);
await click('[data-test-button="members-filter-actions"]');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "test+test"')
.to.equal(0);
expect(find(operatorSelect)).to.have.value('contains');
expect(find(valueInput)).to.have.value(`test+test`);
});
it('can filter by next billing date', async function () { it('can filter by next billing date', async function () {
clock = sinon.useFakeTimers({ clock = sinon.useFakeTimers({
now: moment('2022-03-01 09:00:00.000Z').toDate(), now: moment('2022-03-01 09:00:00.000Z').toDate(),

View file

@ -1930,36 +1930,6 @@
resolved "https://registry.yarnpkg.com/@miragejs/pretender-node-polyfill/-/pretender-node-polyfill-0.1.2.tgz#d26b6b7483fb70cd62189d05c95d2f67153e43f2" resolved "https://registry.yarnpkg.com/@miragejs/pretender-node-polyfill/-/pretender-node-polyfill-0.1.2.tgz#d26b6b7483fb70cd62189d05c95d2f67153e43f2"
integrity sha512-M/BexG/p05C5lFfMunxo/QcgIJnMT2vDVCd00wNqK2ImZONIlEETZwWJu1QtLxtmYlSHlCFl3JNzp0tLe7OJ5g== integrity sha512-M/BexG/p05C5lFfMunxo/QcgIJnMT2vDVCd00wNqK2ImZONIlEETZwWJu1QtLxtmYlSHlCFl3JNzp0tLe7OJ5g==
"@nexes/mongo-knex@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@nexes/mongo-knex/-/mongo-knex-0.5.0.tgz#58566614ca240bdf84a270117d72b46511b17743"
integrity sha512-6wiTbJpy7I2xsxuvwavuwDEtJfoiaxAy4PGPFEiVziQyH3SjOFbwyqnlrKPvhNHCj2YFQHcE8rnJ3JawJVtXOA==
dependencies:
debug "^4.3.1"
lodash "^4.17.21"
"@nexes/mongo-utils@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@nexes/mongo-utils/-/mongo-utils-0.3.1.tgz#3a1b89ec4585478dbb41277dc1fdb2689deb3b9d"
integrity sha512-SpDr6i98GeGA2vajQtliAsUqvFbawmzC6wgaC9/+9P8R0/o+71WTzvyPNYHnXNDqy0dpxq2FX78DdN6FTSKjKA==
dependencies:
lodash "^4.17.11"
"@nexes/nql-lang@0.0.1", "@nexes/nql-lang@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@nexes/nql-lang/-/nql-lang-0.0.1.tgz#a13c023873f9bc11b9e4e284449c6cfbeccc8011"
integrity sha1-oTwCOHP5vBG55OKERJxs++zMgBE=
"@nexes/nql@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@nexes/nql/-/nql-0.6.0.tgz#aec2d36d0ff5300b79e950a37f8c29b195f8152b"
integrity sha512-iI5fQPVfBAX9iM6P3S35XQhp7z7OS+7Ju7GMJGPxouBSDOkppNKh3zc4QGnrt9oMwbUN4hkZ2dsMwLs9VLmDAQ==
dependencies:
"@nexes/mongo-knex" "0.5.0"
"@nexes/mongo-utils" "^0.3.1"
"@nexes/nql-lang" "^0.0.1"
mingo "^2.2.2"
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@ -2164,6 +2134,36 @@
mobiledoc-dom-renderer "0.7.0" mobiledoc-dom-renderer "0.7.0"
mobiledoc-text-renderer "0.4.0" mobiledoc-text-renderer "0.4.0"
"@tryghost/mongo-knex@^0.6.2":
version "0.6.2"
resolved "https://registry.yarnpkg.com/@tryghost/mongo-knex/-/mongo-knex-0.6.2.tgz#8eb246d9311fce6e8fcdced263c1efc8a507b3b8"
integrity sha512-Ef1/TE74ZQaMPMy5dMmmtlqmFq3F8GtzRSvPbaNnPMN1Jn0200CQP8L5akh0r77YGtCKj5foMNsvmlTe5DqmRw==
dependencies:
debug "^4.3.3"
lodash "^4.17.21"
"@tryghost/mongo-utils@^0.3.3":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@tryghost/mongo-utils/-/mongo-utils-0.3.3.tgz#1f35b9e9acd2762d63c72cfd097376dd8049d59c"
integrity sha512-9Qo4jKBr8cTzgZriGQIfpq0X5bJDPDyqlLxeWHNyhIT3J8R2Mtp83zGSeuuwng33JO1cFZKMyaAQs2YTdZWeIA==
dependencies:
lodash "^4.17.11"
"@tryghost/nql-lang@0.3.0", "@tryghost/nql-lang@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@tryghost/nql-lang/-/nql-lang-0.3.0.tgz#47e3658e46fc89222095d6cfa1927291901b2e73"
integrity sha512-rjE0r0Fi5TCjFOL0p8wllbSb42YSgLqEXnS5AgxvhOgjMjHYkFfDX7SWN6eGFt2HeW8B15LxvG2x4eFWNoB1QA==
"@tryghost/nql@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@tryghost/nql/-/nql-0.9.0.tgz#706d91add48043303260f92c67d7f6b8c429e982"
integrity sha512-1b0YHY9aOI74YgA8zXA962BCYR3fofZaM1NLmoXXLwSORT9Q4yajNygMZH328NqCUiyg6KRjNnMyC/E8kLwBgQ==
dependencies:
"@tryghost/mongo-knex" "^0.6.2"
"@tryghost/mongo-utils" "^0.3.3"
"@tryghost/nql-lang" "^0.3.0"
mingo "^2.2.2"
"@tryghost/string@0.1.23": "@tryghost/string@0.1.23":
version "0.1.23" version "0.1.23"
resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.1.23.tgz#7d5f556b7e99b7c5d5e4c6966626afc048b21290" resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.1.23.tgz#7d5f556b7e99b7c5d5e4c6966626afc048b21290"
@ -5794,7 +5794,7 @@ debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, debug@^2.3.
dependencies: dependencies:
ms "2.0.0" ms "2.0.0"
debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2, debug@~4.3.1, debug@~4.3.2: debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@~4.3.1, debug@~4.3.2:
version "4.3.3" version "4.3.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==