diff --git a/ui/app/styles/components/action-block.scss b/ui/app/styles/components/action-block.scss index fa29cf6834f3..137b4fa540c9 100644 --- a/ui/app/styles/components/action-block.scss +++ b/ui/app/styles/components/action-block.scss @@ -12,6 +12,7 @@ display: grid; padding: $spacing-m $spacing-l; line-height: inherit; + grid-gap: $spacing-m; @include until($mobile) { @include stacked-grid(); @@ -20,7 +21,7 @@ .action-block-info { @include until($mobile) { - @include stacked-grid(); + @include stacked-content(); } } @@ -42,17 +43,25 @@ } } +/* Action Block Grid */ .replication-actions-grid-layout { display: flex; flex-wrap: wrap; + margin: $spacing-m 0; + @include until($tablet) { + display: block; + } } .replication-actions-grid-item { flex-basis: 50%; - padding: 5px; + padding: $spacing-s; } .replication-actions-grid-item .action-block { height: 100%; width: 100%; + @include until($tablet) { + height: inherit; + } } diff --git a/ui/app/styles/components/modal.scss b/ui/app/styles/components/modal.scss index 01a8c899b868..34911989d518 100644 --- a/ui/app/styles/components/modal.scss +++ b/ui/app/styles/components/modal.scss @@ -39,6 +39,11 @@ } } +.modal-card-title.title { + display: flex; + align-items: center; +} + pre { background-color: inherit; } @@ -52,3 +57,20 @@ pre { color: $yellow-dark; } } + +.modal-confirm-section .is-help { + color: $grey; + margin: $spacing-xxs 0; + strong { + color: inherit; + } +} + +.modal-confirm-section { + margin: $spacing-xl 0 $spacing-m; +} + +.modal-card-foot-outlined { + background: #f7f8fa; + border-top: 1px solid #bac1cc; +} diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 1e0e528d6d16..1d01a39b964d 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -158,3 +158,8 @@ .has-border-danger { border: 1px solid $danger; } + +ul.bullet { + list-style: disc; + padding-left: $spacing-m; +} diff --git a/ui/app/templates/vault/cluster/replication-dr-promote/details.hbs b/ui/app/templates/vault/cluster/replication-dr-promote/details.hbs index dff7e35ea5c5..5efe8b721ff2 100644 --- a/ui/app/templates/vault/cluster/replication-dr-promote/details.hbs +++ b/ui/app/templates/vault/cluster/replication-dr-promote/details.hbs @@ -1,44 +1,44 @@ <section class="section"> <div class="container is-widescreen"> <ReplicationPage @model={{model}} as |Page|> - <Page.header - @showTabs={{false}} + <Page.header + @showTabs={{true}} + /> + {{#if Page.isDisabled}} + <EmptyState + @title="Disaster Recovery secondary not set up" + @message={{Page.message}} + @icon="alert-circle-outline" + @bottomBorder={{true}} + > + <nav class="breadcrumb"> + <ul class="is-grouped-split"> + <li> + {{#link-to "vault.cluster.secrets.backends" }} + <span class="sep"/> + Go back + {{/link-to}} + </li> + <li> + <LearnLink @path="/vault/operations/ops-disaster-recovery" @class="has-text-grey"> + Need help? + </LearnLink> + </li> + </ul> + </nav> + </EmptyState> + {{else}} + <Page.dashboard + {{!-- passing in component to render so that the yielded components are flexible based on the dashboard --}} + @componentToRender='replication-secondary-card' as |Dashboard|> + <Dashboard.card + @title="States" /> - {{#if Page.isDisabled}} - <EmptyState - @title="Disaster Recovery secondary not set up" - @message={{Page.message}} - @icon="alert-circle-outline" - @bottomBorder={{true}} - > - <nav class="breadcrumb"> - <ul class="is-grouped-split"> - <li> - {{#link-to "vault.cluster.secrets.backends" }} - <span class="sep"/> - Go back - {{/link-to}} - </li> - <li> - <LearnLink @path="/vault/operations/ops-disaster-recovery" @class="has-text-grey"> - Need help? - </LearnLink> - </li> - </ul> - </nav> - </EmptyState> - {{else}} - <Page.dashboard - {{!-- passing in component to render so that the yielded components are flexible based on the dashboard --}} - @componentToRender='replication-secondary-card' as |Dashboard|> - <Dashboard.card - @title="States" - /> - <Dashboard.card - @title="Primary cluster" - /> - <Dashboard.rows/> - </Page.dashboard> + <Dashboard.card + @title="Primary cluster" + /> + <Dashboard.rows/> + </Page.dashboard> {{/if}} </ReplicationPage> </div> diff --git a/ui/app/templates/vault/cluster/replication-dr-promote/index.hbs b/ui/app/templates/vault/cluster/replication-dr-promote/index.hbs index 2c4ef45f4a3f..71b515f5ef17 100644 --- a/ui/app/templates/vault/cluster/replication-dr-promote/index.hbs +++ b/ui/app/templates/vault/cluster/replication-dr-promote/index.hbs @@ -1,62 +1,40 @@ -<SplashPage as |Page|> - <Page.header> - <h1 class="title is-4"> - Disaster Recovery secondary is enabled - </h1> - </Page.header> - <Page.content> - <nav class="tabs sub-nav is-marginless"> - <ul> - <li class="{{if (eq action '') 'is-active'}}"> - {{#link-to 'vault.cluster.replication-dr-promote' (query-params action='')}} - Operation token - {{/link-to}} - </li> - <li class="{{if (eq action 'update') 'is-active'}}"> - {{#link-to 'vault.cluster.replication-dr-promote' (query-params action='update')}} - Update primary - {{/link-to}} - </li> - <li class="{{if (eq action 'promote') 'is-active'}}"> - {{#link-to 'vault.cluster.replication-dr-promote' (query-params action='promote')}} - Promote - {{/link-to}} - </li> - </ul> - </nav> - {{#if (eq action 'promote')}} - <AlertBanner - @type="warning" - @title="Caution" - @message="Vault Replication is not designed for active-active usage and enabling two performance primaries should never be done, as it can lead to data loss if they or their secondaries are ever reconnected." - @class="unseal-warning" - data-test-cluster-status +<section class="section"> + <div class="container is-widescreen"> + <ReplicationPage @model={{model}} as |Page|> + <Page.header + @showTabs={{true}} /> - <ReplicationActions - @replicationMode="dr" - @selectedAction="promote" - @model={{model}} - /> - {{/if}} - {{#if (eq action 'update')}} - <ReplicationActions - @replicationMode="dr" - @selectedAction="update-primary" - @model={{model}} - /> - {{/if}} - {{#unless action}} - <ShamirFlow - @action="generate-dr-operation-token" - @buttonText="Promote cluster" - @fetchOnInit=true - @generateAction=true - > - <p> - Generate an Operation Token by entering a portion of the master key. - Once all portions are entered, the generated operation token may be used to manage your secondary Disaster Recovery cluster. - </p> - </ShamirFlow> - {{/unless}} - </Page.content> -</SplashPage> + {{#if Page.isDisabled}} + <EmptyState + @title="Disaster Recovery secondary not set up" + @message={{Page.message}} + @icon="alert-circle-outline" + @bottomBorder={{true}} + > + <nav class="breadcrumb"> + <ul class="is-grouped-split"> + <li> + {{#link-to "vault.cluster.secrets.backends" }} + <span class="sep"/> + Go back + {{/link-to}} + </li> + <li> + <LearnLink @path="/vault/operations/ops-disaster-recovery" @class="has-text-grey"> + Need help? + </LearnLink> + </li> + </ul> + </nav> + </EmptyState> + {{else}} + <section> + <ReplicationActions + @replicationMode="dr" + @model={{model}} + /> + </section> + {{/if}} + </ReplicationPage> + </div> +</section> diff --git a/ui/lib/core/addon/components/confirmation-modal.js b/ui/lib/core/addon/components/confirmation-modal.js new file mode 100644 index 000000000000..2dda7d33f844 --- /dev/null +++ b/ui/lib/core/addon/components/confirmation-modal.js @@ -0,0 +1,35 @@ +/** + * @module ConfirmationModal + * ConfirmationModal components are used to provide an alternative to ConfirmationButton that automatically prompts the user to fill in confirmation text before they can continue with a potentially destructive action. It is built off the Modal component + * + * @example + * ```js + * <ConfirmationModal + * @onConfirm={action "destructiveAction"} + * @title="Do Dangerous Thing?" + * @isActive={{isModalActive}} + * @onClose={{action (mut isModalActive) false}} + * /> + * ``` + * @param {function} onConfirm - onConfirm is the action that happens when user clicks onConfirm after filling in the confirmation block + * @param {boolean} isActive - Controls whether the modal is "active" eg. visible or not. + * @param {string} title - Title of the modal + * @param {function} onClose - specify what to do when user attempts to close modal + * @param {string} [buttonText=Confirm] - Button text on the confirm button + * @param {string} [confirmText=Yes] - The confirmation text that the user must type before continuing + * @param {string} [buttonClass=is-danger] - extra class to add to confirm button (eg. "is-danger") + * @param {sting} [type=warning] - Applies message-type styling to header. Override to default with empty string + * @param {string} [toConfirmMsg] - Finishes the sentence "Type YES to confirm ..." + */ + +import Component from '@ember/component'; +import layout from '../templates/components/confirmation-modal'; + +export default Component.extend({ + layout, + buttonClass: 'is-danger', + buttonText: 'Confirm', + confirmText: 'Yes', + type: 'warning', + toConfirmMsg: '', +}); diff --git a/ui/lib/core/addon/templates/components/confirmation-modal.hbs b/ui/lib/core/addon/templates/components/confirmation-modal.hbs new file mode 100644 index 000000000000..471c14b48d28 --- /dev/null +++ b/ui/lib/core/addon/templates/components/confirmation-modal.hbs @@ -0,0 +1,46 @@ +<Modal + @title={{title}} + @onClose={{onClose}} + @isActive={{isActive}} + @type={{type}} + @showCloseButton={{true}} +> + <section class="modal-card-body"> + <div class="box is-shadowless is-fullwidth is-sideless"> + + {{yield}} + + <div class="modal-confirm-section"> + <p class="has-text-weight-semibold is-size-6"> + Confirm + </p> + <p class="is-help">Type <strong>{{confirmText}}</strong> to confirm {{toConfirmMsg}}</p> + {{input + type="text" + value=confirmationInput + name="confirmationInput" + class="input has-margin-top" + autocomplete="off" + spellcheck="false" + data-test-confirmation-modal-input="confirmationInput" + }} + </div> + </div> + </section> + <footer class="modal-card-foot modal-card-foot-outlined"> + <button + class="button {{buttonClass}}" + disabled={{unless (eq confirmationInput confirmText) true}} + onclick={{onConfirm}} + data-test-confirm-button + > + {{buttonText}} + </button> + <button + class="button is-secondary" + onclick={{action (mut isActive) false}} + data-test-cancel-button> + Cancel + </button> + </footer> +</Modal> diff --git a/ui/lib/core/addon/templates/components/modal.hbs b/ui/lib/core/addon/templates/components/modal.hbs index 5c1f623514e7..f2eb136eaa72 100644 --- a/ui/lib/core/addon/templates/components/modal.hbs +++ b/ui/lib/core/addon/templates/components/modal.hbs @@ -13,7 +13,7 @@ data-test-modal-glyph={{glyph.glyph}} /> {{/if}} - {{title}} + <span>{{title}}</span> </h2> {{#if showCloseButton}} <button class="delete" aria-label="close" onclick={{onClose}} data-test-modal-close-button></button> diff --git a/ui/lib/core/addon/templates/components/replication-action-disable.hbs b/ui/lib/core/addon/templates/components/replication-action-disable.hbs index dede146a3141..ad13eae18c93 100644 --- a/ui/lib/core/addon/templates/components/replication-action-disable.hbs +++ b/ui/lib/core/addon/templates/components/replication-action-disable.hbs @@ -1,67 +1,52 @@ -<h4 class="title is-5 is-marginless"> - Disable Replication -</h4> -<div class="content"> - <p> - Disable {{replicationDisplayMode}} Replication entirely on the cluster. - {{#if model.replicationAttrs.isPrimary}} - Any secondaries will no longer be able to connect. - {{else if (eq model.replicationAttrs.modeForUrl 'bootstrapping')}} - <br> - Since the cluster is currently bootstrapping, we need to know which mode to disable. - Be sure to choose it below. - <label for="replication-mode" class="is-label"> - Replication cluster mode - </label> - <div class="field is-expanded"> - <div class="control select is-fullwidth"> - <select onchange={{action (mut mode) value="target.value"}} id="replication-mode" name="replication-mode"> - {{#each (array 'primary' 'secondary') as |modeOption|}} - <option - selected={{if mode (eq mode modeOption) (eq modeOption 'primary')}} - value={{modeOption}} - > - {{modeOption}} - </option> - {{/each}} - </select> - </div> - </div> - {{else}} - The cluster will no longer be able to connect to the primary. - {{/if}} - <AlertInline - @type="danger" - @message="Caution: re-enabling this node as a primary or secondary will change its cluster ID." - /> - </p> - <p> - In the secondary case this means a wipe of the - underlying storage when connected to a primary, and in the primary case, - secondaries connecting back to the cluster (even if they have connected - before) will require a wipe of the underlying storage. - </p> -</div> -<div class="field"> - <div class="control"> - <ConfirmAction - @buttonClasses="button is-primary" - @confirmTitle="Disable this cluster?" - @confirmMessage="Data in this cluster will no longer be replicated." - @confirmButtonText="Disable" - @horizontalPosition="auto-left" - @onConfirmAction={{action - "onSubmit" - "disable" - (if - (eq model.replicationAttrs.modeForUrl 'bootstrapping') - mode - model.replicationAttrs.modeForUrl - ) - }} - data-test-disable-replication="true" +<div class="action-block is-rounded" data-test-disable-replication> + <div class="action-block-info"> + <h4 class="title is-5 is-marginless"> + Disable Replication + </h4> + <p> + Disable {{replicationDisplayMode}} Replication entirely on the cluster. + </p> + </div> + + <div class="action-block-action"> + <button + class="button is-danger" + onclick={{action (mut isModalActive) true}} + data-test-replication-action-trigger > Disable Replication - </ConfirmAction> + </button> </div> </div> + +<ConfirmationModal + @title="Disable Replication?" + @onClose={{action (mut isModalActive) false}} + @isActive={{isModalActive}} + @confirmText={{if (eq replicationDisplayMode "DR") "Disaster Recovery" replicationDisplayMode}} + @toConfirmMsg="disabling {{replicationDisplayMode}} Replication on this cluster" + @onConfirm={{action + "onSubmit" + "disable" + (if + (eq model.replicationAttrs.modeForUrl 'bootstrapping') + mode + model.replicationAttrs.modeForUrl + ) + }} +> + <p class="has-bottom-margin-m"> + {{#if (and model.replicationAttrs.isPrimary (eq replicationDisplayMode "DR"))}}This cannot be undone. {{/if}} + Disabling {{replicationDisplayMode}} Replication entirely on this {{if (eq model.replicationAttrs.isPrimary true) "primary" "secondary"}} cluster means that: + </p> + <ul class="bullet"> + {{#if model.replicationAttrs.isPrimary}} + <li>Secondaries will no longer be able to connect</li> + <li>We will wipe the underlying storage of connected secondaries</li> + <li>Secondaries connecting back to the cluster will require a wipe of the underlying storage</li> + {{else}} + <li>We will wipe the underlying storage of this secondary when connected to a primary</li> + {{/if}} + <li>Re-enabling this node will change its cluster ID</li> + </ul> +</ConfirmationModal> diff --git a/ui/lib/core/addon/templates/components/replication-actions.hbs b/ui/lib/core/addon/templates/components/replication-actions.hbs index 9579ee45904f..3d0bdeeec561 100644 --- a/ui/lib/core/addon/templates/components/replication-actions.hbs +++ b/ui/lib/core/addon/templates/components/replication-actions.hbs @@ -2,14 +2,19 @@ <LayoutLoading /> {{else}} <MessageError @errors={{errors}} /> - {{#each (if selectedAction (array selectedAction) (replication-action-for-mode replicationMode model.replicationAttrs.modeForUrl)) as |replicationAction index|}} - <div class="box is-fullwidth is-marginless {{if (gt index 0) 'is-bottomless' 'is-shadowless'}}"> - {{component (concat 'replication-action-' replicationAction) - onSubmit=(action "onSubmit") - replicationMode=replicationMode - model=model - replicationDisplayMode=replicationDisplayMode - }} - </div> - {{/each}} + <div class="replication-actions-grid-layout"> + {{#each + (replication-action-for-mode replicationMode model.replicationAttrs.modeForUrl) + as |replicationAction| + }} + <div class="replication-actions-grid-item"> + {{component (concat 'replication-action-' replicationAction) + onSubmit=(action "onSubmit") + replicationMode=replicationMode + model=model + replicationDisplayMode=replicationDisplayMode + }} + </div> + {{/each}} + </div> {{/if}} diff --git a/ui/lib/core/addon/templates/components/replication-header.hbs b/ui/lib/core/addon/templates/components/replication-header.hbs index 6eeb8351b5e3..d33d9bc37803 100644 --- a/ui/lib/core/addon/templates/components/replication-header.hbs +++ b/ui/lib/core/addon/templates/components/replication-header.hbs @@ -47,14 +47,23 @@ <div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless" data-test-tabs> <nav class="tabs"> <ul> + {{#link-to + "vault.cluster.replication-dr-promote.details" + tagName="li" + activeClass="is-active" + }} + {{#link-to "vault.cluster.replication-dr-promote.details"}} + Details + {{/link-to}} + {{/link-to}} {{#link-to "vault.cluster.replication-dr-promote" tagName="li" activeClass="is-active" - current-when="" + current-when="vault.cluster.replication-dr-promote.index" }} {{#link-to "vault.cluster.replication-dr-promote"}} - Details + Manage {{/link-to}} {{/link-to}} </ul> diff --git a/ui/lib/core/app/components/confirmation-modal.js b/ui/lib/core/app/components/confirmation-modal.js new file mode 100644 index 000000000000..9cfcc6021b68 --- /dev/null +++ b/ui/lib/core/app/components/confirmation-modal.js @@ -0,0 +1 @@ +export { default } from 'core/components/confirmation-modal'; diff --git a/ui/tests/integration/components/confirmation-modal-test.js b/ui/tests/integration/components/confirmation-modal-test.js new file mode 100644 index 000000000000..7b9b62e60295 --- /dev/null +++ b/ui/tests/integration/components/confirmation-modal-test.js @@ -0,0 +1,34 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import sinon from 'sinon'; +import { fillIn, find, render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | confirmation-modal', function(hooks) { + setupRenderingTest(hooks); + + test('it renders with disabled confirmation button until input matches', async function(assert) { + let spy = sinon.spy(); + this.set('onConfirm', spy); + + await render(hbs` + <div id="modal-wormhole"></div> + <ConfirmationModal + @isActive={true} + @onConfirm={this.onConfirm} + @buttonText="Plz Continue" + @confirmText="Destructive Thing" + /> + `); + + assert.dom('[data-test-confirm-button]').isDisabled(); + assert.equal( + find('[data-test-confirm-button]').textContent.trim(), + 'Plz Continue', + 'Confirm button has specified value' + ); + + await fillIn('[data-test-confirmation-modal-input="confirmationInput"]', 'Destructive Thing'); + assert.dom('[data-test-confirm-button]').isNotDisabled(); + }); +}); diff --git a/ui/tests/integration/components/replication-actions-test.js b/ui/tests/integration/components/replication-actions-test.js index 309cd51d0517..fbe5eb176476 100644 --- a/ui/tests/integration/components/replication-actions-test.js +++ b/ui/tests/integration/components/replication-actions-test.js @@ -39,21 +39,21 @@ module('Integration | Component | replication actions', function(hooks) { }); let testCases = [ - ['dr', 'primary', 'disable', 'Disable Replication', null, ['disable', 'primary']], - ['performance', 'primary', 'disable', 'Disable Replication', null, ['disable', 'primary']], - ['dr', 'secondary', 'disable', 'Disable Replication', null, ['disable', 'secondary']], - ['performance', 'secondary', 'disable', 'Disable Replication', null, ['disable', 'secondary']], - ['dr', 'primary', 'recover', 'Recover', null, ['recover']], - ['performance', 'primary', 'recover', 'Recover', null, ['recover']], - ['performance', 'secondary', 'recover', 'Recover', null, ['recover']], + ['dr', 'primary', 'disable', 'Disable Replication', null, ['disable', 'primary'], false], + ['performance', 'primary', 'disable', 'Disable Replication', null, ['disable', 'primary'], false], + ['dr', 'secondary', 'disable', 'Disable Replication', null, ['disable', 'secondary'], false], + ['performance', 'secondary', 'disable', 'Disable Replication', null, ['disable', 'secondary'], false], + ['dr', 'primary', 'recover', 'Recover', null, ['recover'], true], + ['performance', 'primary', 'recover', 'Recover', null, ['recover'], true], + ['performance', 'secondary', 'recover', 'Recover', null, ['recover'], true], - ['dr', 'primary', 'reindex', 'Reindex', null, ['reindex']], - ['performance', 'primary', 'reindex', 'Reindex', null, ['reindex']], - ['dr', 'secondary', 'reindex', 'Reindex', null, ['reindex']], - ['performance', 'secondary', 'reindex', 'Reindex', null, ['reindex']], + ['dr', 'primary', 'reindex', 'Reindex', null, ['reindex'], true], + ['performance', 'primary', 'reindex', 'Reindex', null, ['reindex'], true], + ['dr', 'secondary', 'reindex', 'Reindex', null, ['reindex'], true], + ['performance', 'secondary', 'reindex', 'Reindex', null, ['reindex'], true], - ['dr', 'primary', 'demote', 'Demote cluster', null, ['demote', 'primary']], - ['performance', 'primary', 'demote', 'Demote cluster', null, ['demote', 'primary']], + ['dr', 'primary', 'demote', 'Demote cluster', null, ['demote', 'primary'], true], + ['performance', 'primary', 'demote', 'Demote cluster', null, ['demote', 'primary'], true], // we don't do dr secondary promote in this component so just test perf [ 'performance', @@ -65,6 +65,7 @@ module('Integration | Component | replication actions', function(hooks) { await blur('[name="primary_cluster_addr"]'); }, ['promote', 'secondary', { primary_cluster_addr: 'cluster addr' }], + true, ], // don't yet update-primary for dr @@ -80,10 +81,19 @@ module('Integration | Component | replication actions', function(hooks) { await blur('#primary_api_addr'); }, ['update-primary', 'secondary', { token: 'token', primary_api_addr: 'addr' }], + true, ], ]; - for (let [replicationMode, clusterMode, action, headerText, fillInFn, expectedOnSubmit] of testCases) { + for (let [ + replicationMode, + clusterMode, + action, + headerText, + fillInFn, + expectedOnSubmit, + oldVersion, + ] of testCases) { test(`replication mode ${replicationMode}, cluster mode: ${clusterMode}, action: ${action}`, async function(assert) { const testKey = `${replicationMode}-${clusterMode}-${action}`; this.set('model', { @@ -111,16 +121,32 @@ module('Integration | Component | replication actions', function(hooks) { }); this.set('storeService.capabilitiesReturnVal', ['root']); await render( - hbs`{{replication-actions model=model replicationMode=replicationMode selectedAction=selectedAction onSubmit=(action onSubmit)}}` + hbs` + <div id="modal-wormhole"></div> + {{replication-actions model=model replicationMode=replicationMode selectedAction=selectedAction onSubmit=(action onSubmit)}} + ` + ); + assert.equal( + find('h4').textContent.trim(), + headerText, + `${testKey}: renders the correct component header (${oldVersion})` ); - - assert.equal(find('h4').textContent.trim(), headerText, `${testKey}: renders the correct component`); if (typeof fillInFn === 'function') { await fillInFn.call(this); } - await click('[data-test-confirm-action-trigger]'); - await click('[data-test-confirm-button]'); + if (oldVersion) { + await click('[data-test-confirm-action-trigger]'); + await click('[data-test-confirm-button]'); + } else { + await click('[data-test-replication-action-trigger]'); + await fillIn( + '[data-test-confirmation-modal-input]', + replicationMode === 'dr' ? 'Disaster Recovery' : 'Performance' + ); + await blur('[data-test-confirmation-modal-input]'); + await click('[data-test-confirm-button]'); + } }); } });