diff --git a/.eslintrc.js b/.eslintrc.js index 610c2b8f3..e95ebfc63 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -53,6 +53,7 @@ module.exports = { require("eslint-plugin-node").configs.recommended.rules, { // add your custom rules and overrides for node files here + "prefer-const": ["error"] } ) } diff --git a/addon/components/cf-field/input.js b/addon/components/cf-field/input.js index f6ea18287..01d6605a7 100644 --- a/addon/components/cf-field/input.js +++ b/addon/components/cf-field/input.js @@ -25,7 +25,11 @@ export default Component.extend({ type: computed("field.question.__typename", function() { const typename = get(this, "field.question.__typename"); + const meta = get(this, "field.question.meta") || "{}"; + const customtype = JSON.parse(meta).widgetType; + return ( + customtype || (typename && mapping[typename]) || typename.replace(/Question$/, "").toLowerCase() ); diff --git a/addon/components/cf-field/input/powerselect.js b/addon/components/cf-field/input/powerselect.js new file mode 100644 index 000000000..c60f5cf94 --- /dev/null +++ b/addon/components/cf-field/input/powerselect.js @@ -0,0 +1,81 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import layout from "../../../templates/components/cf-field/input/powerselect"; + +/** + * Dropdown component for the single and multiple choice question type + * + * @class CfFieldInputPowerSelectComponent + * @argument {Field} field The field for this input type + */ +export default Component.extend({ + layout, + tagName: "", + intl: service(), + + multiple: computed("field.question.__typename", function() { + return this.get("field.question.__typename").startsWith("Multiple"); + }), + + choices: computed( + "multiple", + "field.question.{choiceOptions,multipleChoiceOptions}.edges", + function() { + const options = this.get("multiple") + ? this.get("field.question.multipleChoiceOptions") + : this.get("field.question.choiceOptions"); + + return options.edges.map(edge => edge.node); + } + ), + + selected: computed( + "field.answer.{_valueKey,listValue,stringValue}", + function() { + const key = this.get("field.answer._valueKey"); + const answers = this.get(`field.answer.${key}`); + + if (!answers) { + return null; + } + + const selection = this.get("choices").filter(choice => { + return answers.includes(choice.slug); + }); + + return key === "stringValue" ? selection[0] : selection; + } + ), + + componentName: computed("multiple", function() { + return this.get("multiple") ? "power-select-multiple" : "power-select"; + }), + + searchEnabled: computed("choices", function() { + return this.get("choices").length > 10; + }), + + placeholder: computed("multiple", function() { + const suffix = this.get("multiple") ? "multiple" : "single"; + const path = `caluma.form.power-select.placeholder-${suffix}`; + return this.get("intl").t(path); + }), + + actions: { + change: function(choices) { + let value = null; + + if (Array.isArray(choices)) { + value = choices.map(choice => choice.slug); + } else if (choices !== null) { + value = choices.slug; + } + // ELSE will never be taken as long as we don't allow for empty + // selections in single choice fields. Empty selections must first be + // implemented/allowed by the backend. + + this.onSave(value); + } + } +}); diff --git a/addon/components/cfb-form-editor/question.js b/addon/components/cfb-form-editor/question.js index c0ec27ebe..48e34184a 100644 --- a/addon/components/cfb-form-editor/question.js +++ b/addon/components/cfb-form-editor/question.js @@ -55,6 +55,19 @@ export default Component.extend(ComponentQueryManager, { this.get("data").perform(); }, + widgetTypes: computed(function() { + return { + ChoiceQuestion: [ + { value: "radio", label: "Radio buttons" }, + { value: "powerselect", label: "Dropdown" } + ], + MultipleChoiceQuestion: [ + { value: "checkbox", label: "Checkboxes" }, + { value: "powerselect", label: "Dropdown" } + ] + }; + }), + data: task(function*() { if (!this.get("slug")) { return A([ @@ -77,7 +90,7 @@ export default Component.extend(ComponentQueryManager, { ]); } - return yield this.get("apollo").watchQuery( + const questions = yield this.get("apollo").watchQuery( { query: formEditorQuestionQuery, variables: { slug: this.get("slug") }, @@ -85,6 +98,14 @@ export default Component.extend(ComponentQueryManager, { }, "allQuestions.edges" ); + + function setWidgetType(question) { + const meta = JSON.parse(question.node.meta); + question.node.widgetType = meta.widgetType; + return question; + } + + return A(questions.map(setWidgetType)); }).restartable(), model: computed("data.lastSuccessful.value.firstObject.node", function() { @@ -169,6 +190,9 @@ export default Component.extend(ComponentQueryManager, { slug: changeset.get("slug"), isRequired: changeset.get("isRequired"), isHidden: "false", // TODO: this must be configurable + meta: JSON.stringify({ + widgetType: changeset.get("widgetType") + }), isArchived: changeset.get("isArchived"), clientMutationId: v4() }, diff --git a/addon/gql/fragments/field-question.graphql b/addon/gql/fragments/field-question.graphql index 3d1aa9134..b37ce9c5e 100644 --- a/addon/gql/fragments/field-question.graphql +++ b/addon/gql/fragments/field-question.graphql @@ -3,6 +3,7 @@ fragment FieldQuestion on Question { label isRequired isHidden + meta ... on TextQuestion { textMaxLength: maxLength } diff --git a/addon/gql/fragments/question-info.graphql b/addon/gql/fragments/question-info.graphql index 3ae54dfc7..f50a0859f 100644 --- a/addon/gql/fragments/question-info.graphql +++ b/addon/gql/fragments/question-info.graphql @@ -3,5 +3,6 @@ fragment QuestionInfo on Question { label isRequired isHidden + meta isArchived } diff --git a/addon/templates/components/cf-field/input/powerselect.hbs b/addon/templates/components/cf-field/input/powerselect.hbs new file mode 100644 index 000000000..22eb0a8e1 --- /dev/null +++ b/addon/templates/components/cf-field/input/powerselect.hbs @@ -0,0 +1,20 @@ +{{#component componentName + options=choices + selected=selected + disabled=disabled + allowClear=false + preventScroll=true + searchEnabled=searchEnabled + searchField="label" + + placeholder=placeholder + loadingMessage=(t "caluma.form.power-select.options-loading") + searchMessage=(t "caluma.form.power-select.options-empty") + searchPlaceholder=(t "caluma.form.power-select.search-placeholder") + noMatchesMessage=(t "caluma.form.power-select.search-empty") + + onchange=(action "change") + as |choice| +}} + {{choice.label}} +{{/component}} \ No newline at end of file diff --git a/addon/templates/components/cfb-form-editor/question.hbs b/addon/templates/components/cfb-form-editor/question.hbs index b0fc9e129..d5192590d 100644 --- a/addon/templates/components/cfb-form-editor/question.hbs +++ b/addon/templates/components/cfb-form-editor/question.hbs @@ -121,6 +121,18 @@ required=true renderComponent=(component "cfb-form-editor/question/options") }} + + {{f.input + name="widgetType" + label=(t "caluma.form-builder.question.widgetType") + required=true + class="uk-flex uk-flex-between uk-flex-column" + + type="select" + optionTargetPath="value" + optionLabelPath="label" + options=(get widgetTypes f.model.__typename) + }} {{/if}} {{#if (eq f.model.__typename "TableQuestion")}} diff --git a/app/components/cf-field/input/powerselect.js b/app/components/cf-field/input/powerselect.js new file mode 100644 index 000000000..6d759ad1b --- /dev/null +++ b/app/components/cf-field/input/powerselect.js @@ -0,0 +1 @@ +export { default } from "ember-caluma/components/cf-field/input/powerselect"; diff --git a/app/styles/_cfb-powerselect.scss b/app/styles/_cfb-powerselect.scss new file mode 100644 index 000000000..09d1e053e --- /dev/null +++ b/app/styles/_cfb-powerselect.scss @@ -0,0 +1,17 @@ +$ember-power-select-focus-outline: 0; +$ember-power-select-border-color: #e5e5e5; +$ember-power-select-default-border-radius: 0; +$ember-power-select-line-height: 38px; + +$ember-power-select-selected-background: #3799ad; +$ember-power-select-multiple-selection-color: #ffffff; +$ember-power-select-multiple-selection-background-color: $ember-power-select-selected-background; + +$ember-power-select-multiple-option-padding: 0 8px; +$ember-power-select-multiple-option-line-height: 32px; +$ember-power-select-multiple-option-border-color: $ember-power-select-border-color; + +$ember-power-select-highlighted-color: #ffffff; +$ember-power-select-highlighted-background: lighten($ember-power-select-selected-background, 10%); + +@import "ember-power-select"; diff --git a/app/styles/ember-caluma.scss b/app/styles/ember-caluma.scss index 5778e497b..b6b5a8ba1 100644 --- a/app/styles/ember-caluma.scss +++ b/app/styles/ember-caluma.scss @@ -3,6 +3,7 @@ @import "cfb-form-editor/question-list/item"; @import "cfb-loading-dots"; @import "cfb-navigation"; +@import "cfb-powerselect"; .cfb-pointer { cursor: pointer; diff --git a/config/deploy.js b/config/deploy.js index c7fb8bd28..31965c788 100644 --- a/config/deploy.js +++ b/config/deploy.js @@ -2,7 +2,7 @@ "use strict"; module.exports = function() { - let ENV = { + const ENV = { build: {}, git: { repo: "git@github.com:projectcaluma/ember-caluma.git" diff --git a/ember-cli-build.js b/ember-cli-build.js index b4a0b8563..55337e4b6 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -5,7 +5,7 @@ const funnel = require("broccoli-funnel"); const sass = require("sass"); module.exports = function(defaults) { - let app = new EmberAddon(defaults, { + const app = new EmberAddon(defaults, { sassOptions: { implementation: sass }, snippetPaths: ["tests/dummy/app/snippets"], babel: { @@ -18,12 +18,12 @@ module.exports = function(defaults) { app.import("node_modules/typeface-oxygen/index.css"); app.import("node_modules/typeface-oxygen-mono/index.css"); - let oxygen = funnel("node_modules/typeface-oxygen/files", { + const oxygen = funnel("node_modules/typeface-oxygen/files", { include: ["*.woff", "*.woff2"], destDir: "/assets/files/" }); - let oxygenMono = funnel("node_modules/typeface-oxygen-mono/files", { + const oxygenMono = funnel("node_modules/typeface-oxygen-mono/files", { include: ["*.woff", "*.woff2"], destDir: "/assets/files/" }); diff --git a/package.json b/package.json index 0c65faad6..527e92dbf 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "ember-export-application-global": "2.0.0", "ember-load-initializers": "2.0.0", "ember-maybe-import-regenerator": "0.1.6", + "ember-power-select": "2.2.2", "ember-qunit": "4.4.1", "ember-resolver": "5.0.1", "ember-source": "3.7.0", diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index d6435ad7c..0f62ed4e4 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -1,7 +1,7 @@ "use strict"; module.exports = function(environment) { - let ENV = { + const ENV = { modulePrefix: "dummy", environment, rootURL: "/", diff --git a/tests/integration/components/cf-field/input/powerselect-test.js b/tests/integration/components/cf-field/input/powerselect-test.js new file mode 100644 index 000000000..3196b8106 --- /dev/null +++ b/tests/integration/components/cf-field/input/powerselect-test.js @@ -0,0 +1,150 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "ember-qunit"; +import { click, render } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; + +module("Integration | Component | cf-field/input/powerselect", function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.set("singleChoiceField", { + id: "test-single", + answer: { + _valueKey: "stringValue", + stringValue: null + }, + question: { + __typename: "ChoiceQuestion", + choiceOptions: { + edges: [ + { node: { slug: "option-1", label: "Option 1" } }, + { node: { slug: "option-2", label: "Option 2" } }, + { node: { slug: "option-3", label: "Option 3" } } + ] + } + } + }); + + this.set("multipleChoiceField", { + id: "test-multiple", + answer: { + _valueKey: "listValue", + listValue: null + }, + question: { + __typename: "MultipleChoiceQuestion", + multipleChoiceOptions: { + edges: [ + { node: { slug: "option-1", label: "Option 1" } }, + { node: { slug: "option-2", label: "Option 2" } }, + { node: { slug: "option-3", label: "Option 3" } } + ] + } + } + }); + }); + + test("it renders (single)", async function(assert) { + assert.expect(1); + + await render( + hbs`{{cf-field/input/powerselect + field=singleChoiceField + }}` + ); + + assert.dom(".ember-power-select-trigger").exists(); + }); + + test("it saves on click (single)", async function(assert) { + assert.expect(3); + + this.set("onSave", choice => { + this.set("singleChoiceField.answer.stringValue", choice); + }); + + await render( + hbs`{{cf-field/input/powerselect + field=singleChoiceField + onSave=onSave + }}` + ); + + assert.dom(".ember-power-select-trigger").exists(); + await click(".ember-power-select-trigger"); + + assert.dom(".ember-power-select-option").exists({ count: 3 }); + await click(".ember-power-select-option:first-child"); + + assert.equal(this.singleChoiceField.answer.stringValue, "option-1"); + }); + + test("it renders (multiple)", async function(assert) { + assert.expect(1); + + await render( + hbs`{{cf-field/input/powerselect + field=multipleChoiceField + }}` + ); + + assert.dom(".ember-power-select-trigger").exists(); + }); + + test("it saves on click (multiple)", async function(assert) { + assert.expect(3); + + this.set("onSave", choices => { + this.set("multipleChoiceField.answer.listValue", choices); + }); + + await render( + hbs`{{cf-field/input/powerselect + field=multipleChoiceField + onSave=onSave + }}` + ); + + // Check if select is being rendered. + assert.dom(".ember-power-select-trigger").exists(); + // Open the dropdown menu. + await click(".ember-power-select-trigger"); + // Check if dropdown content is being rendered. + assert.dom(".ember-power-select-option").exists({ count: 3 }); + // Click first item from dropdown. + await click(".ember-power-select-option:nth-child(1)"); + // Reopen dropdown menu. + await click(".ember-power-select-trigger"); + // Select second item from dropdown . + await click(".ember-power-select-option:nth-child(2)"); + + assert.deepEqual(this.multipleChoiceField.answer.listValue, [ + "option-1", + "option-2" + ]); + }); + + test("it handles empty selections (multiple)", async function(assert) { + assert.expect(3); + + this.set("onSave", choices => { + this.set("multipleChoiceField.answer.listValue", choices); + }); + + await render( + hbs`{{cf-field/input/powerselect + field=multipleChoiceField + onSave=onSave + }}` + ); + + this.set("multipleChoiceField.answer.listValue", ["option-1"]); + + assert.dom(".ember-power-select-trigger").exists(); + await click(".ember-power-select-trigger"); + assert.dom(".ember-power-select-option[aria-selected='true']").exists(); + await click(".ember-power-select-option[aria-selected='true']"); + + assert.deepEqual(this.multipleChoiceField.answer.listValue, []); + }); +}); diff --git a/translations/de-de.yaml b/translations/de-de.yaml index 9fb0d5456..1662e602d 100644 --- a/translations/de-de.yaml +++ b/translations/de-de.yaml @@ -5,6 +5,15 @@ dummy: caluma: form: optional: "Optional" + + power-select: + placeholder-single: "Wählen Sie eine Option aus" + placeholder-multiple: "Wählen Sie eine oder mehrere Optionen aus" + options-loading: "Lade Optionen..." + options-empty: "Keine Optionen vorhanden" + search-placeholder: "Hier tippen um zu suchen" + search-empty: "Keine Optionen gefunden" + form-builder: global: save: "Speichern" @@ -38,6 +47,7 @@ caluma: slug: "Slug" type: "Typ" isRequired: "Erforderlich" + widgetType: "Widget Typ" isArchived: "Archiviert" type-disabled: "Sobald die Frage erstellt ist kann der Typ nicht mehr geändert werden um korrupte Daten zu verhindern." @@ -63,8 +73,8 @@ caluma: types: IntegerQuestion: "Ganze Zahl" FloatQuestion: "Gleitkommazahl" - MultipleChoiceQuestion: "Checkbox" - ChoiceQuestion: "Radio" + MultipleChoiceQuestion: "Mehrfachauswahl" + ChoiceQuestion: "Einzelauswahl" TextQuestion: "Text" TextareaQuestion: "Textbereich" diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 05c380511..cfcef9946 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -6,6 +6,14 @@ caluma: form: optional: "Optional" + power-select: + placeholder-single: "Choose one option" + placeholder-multiple: "Choose one or more options" + options-loading: "Loading options..." + options-empty: "No options available" + search-placeholder: "Type here to search options" + search-empty: "Search didn't match any options" + validation: blank: "This field can't be blank" tooLong: "The value of this field can't be longer than {max} characters" @@ -46,6 +54,7 @@ caluma: slug: "Slug" type: "Type" isRequired: "Required" + widgetType: "Widget type" isArchived: "Archived" type-disabled: "Once created, the type can't be changed to prevent corrupt data." @@ -72,8 +81,8 @@ caluma: types: IntegerQuestion: "Integer" FloatQuestion: "Float" - MultipleChoiceQuestion: "Checkbox" - ChoiceQuestion: "Radio" + MultipleChoiceQuestion: "Choices" + ChoiceQuestion: "Choice" TextQuestion: "Text" TextareaQuestion: "Textarea" TableQuestion: "Table" diff --git a/yarn.lock b/yarn.lock index 5f3d0c8bb..9c16aa54f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5097,6 +5097,15 @@ ember-auto-import@^1.2.10, ember-auto-import@^1.2.13: walk-sync "^0.3.3" webpack "^4.12.0" +ember-basic-dropdown@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/ember-basic-dropdown/-/ember-basic-dropdown-1.1.2.tgz#6558eb2aa34d2feeb66e9de1feea560d46edc697" + integrity sha512-l38MNIUOI1nAKxSUlDI1wrP52a55HxN2dikDUwJOqx7NytK0/woPyy3uVUe7gfT2gJ4HCbRlL/7y0csvP0iMPg== + dependencies: + ember-cli-babel "^7.2.0" + ember-cli-htmlbars "^3.0.1" + ember-maybe-in-element "^0.2.0" + ember-changeset-validations@1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/ember-changeset-validations/-/ember-changeset-validations-1.3.3.tgz#04224e3d398801d4aec192099d1fed0b95622b2d" @@ -5238,7 +5247,7 @@ ember-cli-babel@^7.0.0, ember-cli-babel@^7.1.0, ember-cli-babel@^7.1.2, ember-cl ensure-posix-path "^1.0.2" semver "^5.5.0" -ember-cli-babel@^7.4.0, ember-cli-babel@^7.4.3, ember-cli-babel@^7.5.0: +ember-cli-babel@^7.2.0, ember-cli-babel@^7.4.0, ember-cli-babel@^7.4.3, ember-cli-babel@^7.5.0: version "7.5.0" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.5.0.tgz#af654dcef23630391d2efe85aaa3bdf8b6ca17b7" integrity sha512-wWXqPPQNRxCtEHvYaLBNiIVgCVCy8YqZ0tM8Dpql1D5nGnPDbaK073sS1vlOYBP7xe5Ab2nXhvQkFwUxFacJ2g== @@ -5419,7 +5428,7 @@ ember-cli-htmlbars-inline-precompile@^2.1.0: heimdalljs-logger "^0.1.9" silent-error "^1.1.0" -ember-cli-htmlbars@3.0.1, ember-cli-htmlbars@^3.0.0: +ember-cli-htmlbars@3.0.1, ember-cli-htmlbars@^3.0.0, ember-cli-htmlbars@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-3.0.1.tgz#01e21f0fd05e0a6489154f26614b1041769e3e58" integrity sha512-pyyB2s52vKTXDC5svU3IjU7GRLg2+5O81o9Ui0ZSiBS14US/bZl46H2dwcdSJAK+T+Za36ZkQM9eh1rNwOxfoA== @@ -5833,7 +5842,7 @@ ember-composable-helpers@^2.1.0: broccoli-funnel "^1.0.1" ember-cli-babel "^6.6.0" -ember-concurrency@0.8.27, ember-concurrency@^0.8.21: +ember-concurrency@0.8.27, ember-concurrency@^0.8.21, ember-concurrency@^0.8.26: version "0.8.27" resolved "https://registry.yarnpkg.com/ember-concurrency/-/ember-concurrency-0.8.27.tgz#6dd1b9928cf5d13d5ae9c8cd3d5869f6ff81a9a9" integrity sha512-2IujJ0Y79a+sHvEOPhUtZ7Ga8HDrwjbQqO7aZ88b0KCsXPro7birQFB508njQSQ0mxrsR9qzDv/KS5V67Cy5dA== @@ -6108,6 +6117,13 @@ ember-maybe-import-regenerator@0.1.6, ember-maybe-import-regenerator@^0.1.5, emb ember-cli-babel "^6.0.0-beta.4" regenerator-runtime "^0.9.5" +ember-maybe-in-element@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/ember-maybe-in-element/-/ember-maybe-in-element-0.2.0.tgz#9ac51cbbd9d83d6230ad996c11e33f0eca3032e0" + integrity sha512-R5e6N8yDbfNbA/3lMZsFs2KEzv/jt80TsATiKMCqdqKuSG82KrD25cRdU5VkaE8dTQbziyBeuJs90bBiqOnakQ== + dependencies: + ember-cli-babel "^7.1.0" + ember-modal-dialog@2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/ember-modal-dialog/-/ember-modal-dialog-2.4.3.tgz#8e254185e95dae6ccf46987822a095acf42561b1" @@ -6128,6 +6144,18 @@ ember-one-way-select@^4.0.0: ember-cli-htmlbars "^2.0.1" ember-invoke-action "^1.5.0" +ember-power-select@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/ember-power-select/-/ember-power-select-2.2.2.tgz#38ae6f0422f281baa41515074b6d7840be7f28c0" + integrity sha512-NBW6kwkTD8rbBxENWE82p5VH20wTAAgkrjEyPzCFJt+xY2O/HmRxCa7OdU3JqPXO3cU4ZsOkfgnaAvudg+ukOA== + dependencies: + ember-basic-dropdown "^1.1.0" + ember-cli-babel "^7.2.0" + ember-cli-htmlbars "^3.0.1" + ember-concurrency "^0.8.26" + ember-text-measurer "^0.5.0" + ember-truth-helpers "^2.1.0" + ember-promise-helpers@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/ember-promise-helpers/-/ember-promise-helpers-1.0.6.tgz#6ff9f451330f4608ec4696de473a7f54bc179236" @@ -6290,6 +6318,13 @@ ember-tether@^1.0.0-beta.2: ember-cli-node-assets "^0.2.2" tether "^1.4.0" +ember-text-measurer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/ember-text-measurer/-/ember-text-measurer-0.5.0.tgz#b907aeb8cbc04560e5070dc0347cdd35d0040d0d" + integrity sha512-YhcOcce8kaHp4K0frKW7xlPJxz82RegGQCVNTcFftEL/jpEflZyFJx17FWVINfDFRL4K8wXtlzDXFgMOg8vmtQ== + dependencies: + ember-cli-babel "^7.1.0" + ember-toggle@^5.3.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ember-toggle/-/ember-toggle-5.3.2.tgz#456e97c3a5450ccdadefe2378dc70e14fcf6cc53"