How to build a Search and Replace app in Salesforce Marketing Cloud with Vue, SSJS and REST API

How to build a Search and Replace app in Salesforce Marketing Cloud with Vue, SSJS and REST API

This article explains how to build a Search and Replace application in Salesforce Marketing Cloud with the use of Bootstrap, Vue.js, server-side JavaScript and REST API.

The problem

Marketing Cloud is a great tool. It works wonders for Marketing Automation and the interface is clear and simple to use.

But, as our assets folder grows, we all end up facing the same issue: the blocks are harder to find and the content of the blocks can’t be searched and modified in bulk.

Have you ever been assigned a task to find and replace a broken link or any other occurrence in a haystack of countless content blocks?

It turns out, the Content Builder search engine only returns approximate results when it comes to the content and there is no way around it.

Unless… there is another solution: we build this feature ourselves!

The tools

Before we proceed with defining the flow, we first need to define the tools we are going to use:

Keep in mind, that the only reason we use jQuery is because Bootstrap needs it to to run its components. There is nothing jQuery does that plain JavaScript can’t do, but in this case, it’s just easier this way.

The solution

Here is how the Search and Replace application works:

  1. User types the keywords in the search bar and pushes the action button.
  2. AJAX request with the keywords is sent to the Form Handler page.
  3. Form Handler page retrieves all the blocks with approximate occurrences using REST API, then goes through every block, looking for the exact match.
  4. If the application was set to Search, nothing else happens. But if it was set to Search and Replace, for each exact match, a REST API call is sent to update the asset block.
  5. Form Handler page returns the result to the page with the search bar.
  6. Search bar page displays the names and the types of the asset blocks where the keyword was found (and modified).
  7. Download button allows the results to be downloaded in the CSV file format.

Building the application

Before building the application, please make sure to have a couple of content blocks of different type populated with some keywords for testing purposes.

Cloud page

The application is managed by 2 fundamental functionalities: status and action.

Status defines the different steps in the application management:

  • Status = 100: the application is initialized.
  • Status = 101: the request was sent to the Form Handler.
  • Status = 200: the Form Handler returned the results.
  • Status = 400: the Form Handler returned no results.
  • Status = something else: the application failed.

As for the action, it has 2 possible values: search and replace:

  • Search: only the search field is displayed.
  • Replace: both search and replace fields are displayed. Also, the user has to confirm the replacement action before the request is sent to the Form Handler page.

Bootstrap is in charge of the appearance, the confirmation pop-up and the segmented button functionality.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta name="viewport"
        content="width=device-width, height=device-height, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no">
    <title>Search & Replace App</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
</head>
<body>
<div id="app" class="container">
    <div class="mx-auto my-5">
        <template>
            <h2>Search & Replace</h2>
            <form class="needs-validation my-4" ref="form" action="{{ YOUR FORM HANDLER URL }}" method="post">
                <div class="input-group input-group-lg">
                    <input 
                        class="form-control"
                        v-model="form.search"
                        @keyup="status = 100"
                        type="text"
                        minlength="3"
                        maxlength="254"
                        placeholder="Search..."
                        required
                    >
                    <div class="invalid-feedback order-last">
                        Your text should be at least 3 characters long.
                    </div>
                    <input 
                        class="form-control"
                        v-model="form.replace"
                        v-if="action == 'replace'"
                        @keyup="status = 100"
                        type="text"
                        minlength="3"
                        maxlength="254"
                        placeholder="Replace by..."
                        required
                    >
                    <div v-if="action == 'replace'" class="invalid-feedback order-last">
                        Your text should be at least 3 characters long.
                    </div>
                    <div class="input-group-append">
                        <button 
                            @click.prevent="takeAction()"
                            type="button" 
                            class="btn btn-primary"
                            :disabled="(status == 101)"
                        >
                        {{ button }}
                        </button>
                        <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                            <span class="sr-only">Toggle Dropdown</span>
                        </button>
                        <div class="dropdown-menu">
                            <a class="dropdown-item" href="#" @click.prevent="action = 'search'">Search</a>
                            <a class="dropdown-item" href="#" @click.prevent="action = 'replace'">Search & replace</a>
                        </div>
                    </div>
                </div>
            </form>
        </template>
        <template v-if="status == 100"></template>
        <template v-else-if="status == 101">
            <div class="text-center text-primary">
                <div class="spinner-border" role="status">
                    <span class="sr-only"></span>
                </div>
            </div>
        </template>
        <template v-else-if="status == 200">
            <h3 v-if="action == 'search'" class="mb-4">Search results for "{{ form.search }}":</h3>
            <h3 v-if="action == 'replace'" class="mb-4">Replaced "{{ form.search }}" by "{{ form.replace }}" in:</h3>
            <ul class="list-group mt-2">
                <li class="list-group-item" v-for="result in results">
                    <strong>{{ result.name }}</strong> 
                    <small class="float-right text-info">{{ result.type }}</small>
                </li>
            </ul>
            <br>
            <button @click.prevent="downloadResults()" class="btn btn-primary">Download results</button>
        </template>
        <template v-else-if="status == 404">
            <div class="text-center">
                <h3>Sorry but nothing was found.</h3>
            </div>
        </template>
        <template v-else>
            <div class="text-center text-danger">
                <h3>Huston, we have a problem.</h3>
            </div>
        </template>
        <div class="modal fade" id="confirmModal" tabindex="-1" role="dialog" aria-labelledby="confirmModal" aria-hidden="true">
            <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="exampleModalLabel">Are you sure?</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">×</span>
                    </button>
                    </div>
                    <div class="modal-body">
                        This operation can't be reverted. Proceed at your own risk.
                    </div>
                    <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Abort</button>
                    <button type="button" class="btn btn-primary" @click="sendFormData(); confirmModal();">Replace all</button>
                </div>
            </div>
            </div>
        </div>
    </div>
</div>
<script src="{{ YOUR JS FOLDER URL }}/app.js"></script>
</body>
</html>

Vue.js application

Please refer to the comments in the code to get a better understanding.

Fire up Vue.js DevTools Chrome extension to observe how the application manages the data.

new Vue({
    el: '#app',
    data: {
        status: 100,
        form: {
            'search': '',
            'replace': ''
        },
        button: 'Choose an action',
        action: '',
        endpoint: '',
        results: {}
    },
    watch: {
        action: function () {
			/* When the action changes, remove the validation
			highlights, hide the search results and change the
			label of the search button */
			
            this.$refs.form.classList.remove('was-validated');
            this.status = 100;

            switch (this.action) {
                case 'search':
                    this.button = 'Search'
                    this.form.replace = ''
                    break;
                case 'replace':
                    this.button = 'Search and replace'
                    break;    
                default:
                    this.button = 'Choose an action'
                    break;
            }
        }
    },
    mounted: function() {
		// Get the URL from the form tag action attribute
        this.endpoint = this.$refs.form.getAttribute('action');
    },
    methods: {

        sendFormData: function() {
			/* Send form data to the Form Handler, set the status
			to "awaiting response", then change it according to
			the request status and return the results */

            this.status = 101;
            var $this = this;

            axios({
                method: 'POST',
                url: this.endpoint,
                data: this.form,
                validateStatus: function() { return true }
            }).then(function(result) {
                $this.status = result.data.Status;
                $this.results = result.data.Results;
                if($this.status == 500) {
                    console.error(result.data.Message);
                }
            }).catch(function(error) {
                console.error(error);
            });
        },

        validateForm: function() {
			/* If the form is valid and the action is set to "replace"
			prompt the user with the pop-up window, then send the data.
			If the action is set to "search" and the form is valid,
			send the data right away */

            if (this.$refs.form.checkValidity() !== false) {
                if(this.action == 'replace') this.confirmModal(); 
				else this.sendFormData();
            }
            this.$refs.form.classList.add('was-validated');

        },

        confirmModal: function() {
			// Toggle the Bootstrap Modal component
            $('#confirmModal').modal('toggle');
        },

        takeAction: function() {
			// Reset the status and validate the form
            if(this.action == 'search' || this.action == 'replace') {
                this.status = 100;
                this.validateForm();
            } else {
                return false;
            }
        },

        downloadResults: function() {
			/* Compile the results sent from the Form Handler
			in a CSV file and trigger the download action */
			
            var csv = 'Id,Name,Type\n';
            var list = JSON.parse(JSON.stringify(this.results));

            for(k in list) {
                csv += (list[k].id + ",");
                csv += (list[k].name + ",");
                csv += (list[k].type + ",");
                csv += "\n";
            }
        
            var el = document.createElement('a');
            el.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv);
            el.target = '_blank';
            el.download = this.action + '-results.csv';
            el.click();
        }
    }
})

Form Handler page

In order to use REST API in Marketing Cloud, you first need to create a server-to-server API integration and fetch the API endpoint URL, ClientId and ClientSecret credentials.

The process of the Form Handler page works as follows:

  1. Retrieve a token for performing the REST API requests.
  2. Make the first request to retrieve all the blocks of type HTML of FreeForm, but filter on 1 result per page. From this operation we can make an estimation of how many pages there are in total if we decide to retrieve 100 results per page (using Math!).
  3. Create a loop that runs as many times as there are pages and retrieves 100 results at a time.
  4. Retrieve the content of the block from each result and look for the exact match for the search keyword.
  5. If the action is set to “Replace“, replace the keyword in the retrieved content and perform a REST API update request to change the content of the block.
  6. Write the results of the operation in JSON format.

The reason behind the estimation and loop processes is merely the fact that REST API only returns paginated results and the number of results is limited by page.

<script runat="server">
    Platform.Load("Core", "1.1.1");

    var postData = Platform.Request.GetPostData();

    var parsedData = Platform.Function.ParseJSON(postData);

    var searchNeedle = parsedData.search;
    var replaceBy = parsedData.replace;

    var config = {
        endpoint: "https://{{ YOUR BASE URL }}.auth.marketingcloudapis.com/v2/token",
        contentType: "application/json",
        credentials: {
            client_id: "{{ YOUR CLIENT ID }}",
            client_secret: "{{ YOUR CLIENT SECRET }}",
            grant_type: "client_credentials"
        }
    }

    try {

        if(searchNeedle == null || searchNeedle.length === 0) throw "No search requested."

        var accessTokenRequest = HTTP.Post(config.endpoint, config.contentType, Stringify(config.credentials));

        if (accessTokenRequest.StatusCode == 200) {
            var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
            var accessToken = tokenResponse.access_token;
            var restInstanceUrl = tokenResponse.rest_instance_url
        } else {
            throw "Access token request has failed."
        }

        if (accessToken != null) {

            var requestUrl = restInstanceUrl + "asset/v1/content/assets/query";
            var headerNames = ["Authorization"];
            var headerValues = ["Bearer " + accessToken];
            var jsonBody = {
                "page": {
                    "page": 1,
                    "pageSize": 1
                },
                "query": {
                    "leftOperand":
                    {
                        "property": "assetType.name",
                        "simpleOperator": "equal",
                        "value": "freeformblock"
                    },
                    "logicalOperator": "OR",
                    "rightOperand":
                    {
                        "property": "assetType.name",
                        "simpleOperator": "equal",
                        "value": "htmlblock"
                    }
                },
                "fields": [
                    "content",
                    "category",
                    "name"
                ]
            };

            /////////////////////////////////////////////////////
            ////// ESTIMATION
            /////////////////////////////////////////////////////

            var res = HTTP.Post(requestUrl, config.contentType, Stringify(jsonBody), headerNames, headerValues);

            if(res.StatusCode != 200) throw "Estimation request has failed."

            var resultJSON = Platform.Function.ParseJSON(String(res.Response));
            var count = resultJSON.count;
            var pageSize = 100;

            /////////////////////////////////////////////////////
            ////// SEARCH
            /////////////////////////////////////////////////////

            var totalPages = Math.round(count / pageSize) + 1;
            var searchResults = [];

            for(var i = 1; i <= totalPages; i++) {

                jsonBody.page.page = i;
                jsonBody.page.pageSize = pageSize;

                var res = HTTP.Post(requestUrl, config.contentType, Stringify(jsonBody), headerNames, headerValues);

                var resultJSON = Platform.Function.ParseJSON(String(res.Response));

                var items = resultJSON.items;

                for(k in items) {

                    var item = items[k];

                    if (String(item.content).indexOf(searchNeedle) != -1) {

                        if(replaceBy != null && replaceBy.length > 0) {

                            /////////////////////////////////////////////////////
                            ////// REPLACE
                            /////////////////////////////////////////////////////

                            var updateUrl = restInstanceUrl + "asset/v1/content/assets/" + item.id;

                            var newContent = replaceAll(String(item.content), searchNeedle, replaceBy)

                            var req = new Script.Util.HttpRequest(updateUrl);
                            req.emptyContentHandling = 0;
                            req.retries = 2;
                            req.continueOnError = true;
                            req.setHeader("Authorization", "Bearer " + accessToken);
                            req.method = "PATCH";
                            req.contentType = config.contentType
                            req.postData = Stringify({ Content: newContent });

                            var resp = req.send();

                            if(resp.statusCode == 200) searchResults.push({
                                id: item.id,
                                name: item.name,
                                type: item.assetType.name
                            });

                        } else {
                            searchResults.push({
                                id: item.id,
                                name: item.name,
                                type: item.assetType.name
                            });
                        }
                    };
                }
            }

            var output = {
                Status: (searchResults.length > 0) ? 200 : 404,
                Results: searchResults
            }

            Write(Stringify(output));

        }

    } catch (error) {
        Write(Stringify({ Status: 500, Message: error }));
    }

    function replaceAll(str, find, replace) {
        var target = str;
        return target.split(find).join(replace);
    }
</script>

See it in action

Conclusion and considerations

Et voilà! Try it out! This application can work miracles in the right hands.

Although, please consider the following:

  • Make sure all the security measures are implemented on the Form Handler page. I didn’t include them in this article to save space.
  • Please make sure to test this application before using, preferably on the test asset blocks created for this purpose.
  • Run a Search request before Search and Replace, as there will be no going back once the asset blocks are updated.
  • This code requires a lot of REST API calls, dont abuse it, as they are limited in the number of use according to your contract with Salesforce.
  • The more asset blocks you query, the slower the application will work.
    Be patient.

Notes and props

In case you were wondering, I could have indeed used jQuery for AJAX calls, but I deeply dislike this framework and I prefer to use it as less as possible. And I dislike LESS as well, because SASS is just better.

This article was made possible thanks to the awesome article from Zuzanna Jarczynska about file uploads with REST API.

Have I missed anything?

Please poke me with a sharp comment below or use the contact form.

Pay me a coffee

Want to say thanks? Pay me a coffee! Remember, I turn coffee into code.

  1. It is wonderful!!!

    i have use case-
    if say search results shows up 10 content blocks, i need only specific content blocks to be replaced, how to acheive this ? this would be great help.

    Like say having checkbox to resultant content block, if checked box ticked then replace all to the checked content box.

    Awaiting for your thoughts on it!!

  2. You’ll need to rewrite the app for this, but it’s totally possible. After the search result, you get the id’s of all the found blocks. Using those ids you can filter on the blocks you want to be affected by the replacement method.


    var ids_of_blocks = [12345,45678,91011];
    function inArray(arr, k) {
    var out = -1;
    for (var i in arr) {
    if (arr[i] == k) out = i;
    }
    return out;
    }
    if (String(item.content).indexOf(searchNeedle) != -1 && !!inArray(ids_of_blocks,item.id) ...

  3. Thank you for turn around.

    I there way to search other than content block, say paste html or template based email.

    Thanks,
    Praveen

  4. HTML blocks yes, that’s what I do in the app. An email… haven tried that yet, but I assume an email is just “template + ampscript references to the content blocks it uses”. Otherwise these are the asset types you can try to query: templatebasedemail, htmlemail and textonlyemail.

  5. I’m not able to retrieve templatebasedemail search text, could you please help me out in this.

    That would be great help.

  6. Do you really need it? templatebasedemail is related to Classic emails and Classic emails were removed from SFMC recently.

  7. Hey Ian, thanks for updating the code to search ‘html’ type. For others reading this comment, in the /assets/query, you can use one of the asset type values as in https://developer.salesforce.com/docs/atlas.en-us.mc-apis.meta/mc-apis/base-asset-types.htm.

    Ian – one question. When I try this, the search and replace works fine (the html gets replaced just fine). But the handler code throws error. The response bascially says this.

    Post Data ==> {“search”:”latest_”,”replace”:”latest”}https://…rest.marketingcloudapis.com/asset/v1/content/assets/query {“page”:{“page”:1,”pageSize”:1},”query”:{“leftOperand”:{“property”:”assetType.name”,”simpleOperator”:”equal”,”value”:”freeformblock”},”logicalOperator”:”OR”,”rightOperand”:{“property”:”assetType.name”,”simpleOperator”:”equal”,”value”:”htmlblock”}},”fields”:[“content”,
    “category”,
    “name”]}Response ==> {“Status”:200,”Results”:[{“id”:7264,”name”:”201812_LLate_Payment_Cell_ID1″,”type”:”htmlblock”}]}
    (function(a,m,i,g,o,s){o=a.createElement(g);s=a.getElementsByTagName(i)[0];o.src=m.origin+m.pathname+”/_t?eventType=CLOUDPAGESVISIT”;o.width=0;o.height=0;o.style.display=”none”;s.appendChild(o);})(document,window.location,”body”,”img”);

    if (_etmc && typeof _etmc.push === ‘function’) {
    _etmc.push([‘setOrgId’, ‘1234’]);
    _etmc.push([‘trackPageView’]);
    }

    The first part is something that I’ve added to debug. Any thoughts on what the error is about? Should I be running this in parent BU and not child BU?

  8. hey Matheswaran, you created your Form Handler page using Classic editor and what you are seeing is the tracking code that is automatically added to the HTML of the page. Try creating the page using Content Builder option and everything should work fine.

Leave a Reply

Your email address will not be published. Required fields are marked *

Up Next:

How to use Vue, Bootstrap and reCAPTCHA to build forms for Salesforce Marketing Cloud

How to use Vue, Bootstrap and reCAPTCHA to build forms for Salesforce Marketing Cloud