✨ Scripting contest using newly launched button field

We’ve just launched the new button field! One of the things this new field can do is run the scripting block in the context of a single record. This makes it a lot easier to use these scripts, since you can trigger them directly from any record you’re looking at, whether it’s from grid view, gallery view, kanban view, or an expanded record.

For this contest, submit a script that takes advantage of the new button field. We’d love to see the creative and useful ways you’re using buttons! For example, we’ve updated the “Product catalog” template to include a handy “Add new line item” button that runs a script to quickly add line items to an invoice:

Here are the docs on how to write scripts that work with the button field. The winner of this contest will win $1,000, with all submissions due by Friday, July 3, 2020 at 11:59pm (PST) .

What exactly should be submitted

To qualify for participating in this content, you should submit:

  • A script using input.recordAsync in the scripting block that works with the new button field
  • A short video demo of the script in action
  • A description of what the script does

Guidelines on submission format

Submit your entry by replying to this thread. Include a description of what your script does, the code for the script, and a link to the video demo. Since your description of what the script does is posted in this thread, the demo video should be concise enough to only demonstrate the script in action (ideally no more than 2 to 3 minutes); we won’t be judging submissions based on video length or production quality.

What rubric we will use for judging submissions

We’ll ask a panel of five of our customer success managers to vote on the script that demonstrate the most creativity and usefulness with the new button field.

Terms and Conditions

All submissions are due by Friday July 3, 2020 at 11:59PM (PST). The content winner will be announced within a week after the submission deadline. Please see our Terms and Conditions for this contest here.

12 Likes

Hi everyone

This is my entry for the button scripting contest. I’ve used three buttons to launch scripts in my base and make the base more “app-like” with the addition of buttons that run specific actions, demoed in the video here.

My “Approve” button runs the following script:

let table = base.getTable("Holiday Requests");
let record = await input.recordAsync('Pick a record', table);
if (record) {
    table.updateRecordAsync(record, {
        'Status': {name: 'Approved'}
    })
    output.text('Approved!');
}

My “Reject” button runs this code (very similar):

let table = base.getTable("Holiday Requests");
let record = await input.recordAsync('Pick a record', table);
if (record) {
    table.updateRecordAsync(record, {
        'Status': {name: 'Rejected'}
    })
    output.text('Rejected');
}

Finally, the “Check” button runs this code:

// set up the table
let table = base.getTable("Holiday Requests");

// set up the view of already approved holidays
let approvedView = table.getView('Approved Requests');

// get the record from the button click
let record = await input.recordAsync('Pick a record', table);

// define an empty array of conflicts for later use
let conflicts = [];

if (record) {
    // output some helpful text for the user
    output.text(`Checking holiday conflicts for:`);
    output.table([{"Name": record.getCellValueAsString('Person'), "Start": record.getCellValue('Start Date'), "End": record.getCellValue('End Date')}])
    output.text(`...please wait`);
    output.text('=====================================================');

    // get the previous approved records
    let approvedQuery = await approvedView.selectRecordsAsync();

    // for each of the approved records
    // check if it overlaps with the current record
    // if it does, push it to the conflicts array
    // otherwise continue
    for (let approved of approvedQuery.records) {
        if (
            record.getCellValue('Start Date') > approved.getCellValue('End Date') || record.getCellValue('End Date') < approved.getCellValue('Start Date')
            ) {
            continue
        } else {
            conflicts.push({
                "Name": approved.getCellValue('Person')[0]['name'],
                "Start": approved.getCellValue('Start Date'),
                "End": approved.getCellValue('End Date')
                });
        }
    }
}

// if there are some conflicting holiday requests then output them
// otherwise, let the user know that the request is OK
if (conflicts.length > 0) {
    output.text('This request conflicts with the following approved requests')
    output.table(conflicts);
} else {
    output.text('This holiday request does not conflict with any already approved requests and can be approved.')
}

(side note: in the video I say that the 3 scripts could be combined into one, but I was thinking of a “vertical” arrangement of the buttons rather than a “horizontal” arrangement, so please ignore that).

16 Likes

This is a great solution, @JonathanBowen!!

3 Likes

Hey all,

Super excited to see these buttons, I think they’ll be a game changer.

I built an “Enrich” button that takes someone’s email address and fills in the rest of the columns with things like where they work, their job title, LinkedIn profile, etc. based on top of Clearbit’s API so you can fill out an actionable prospecting list!

Here’s a quick 1 min video:

My “Enrich” button runs the following code.

Step 1: To get things set up first and foremost, declare the fields you’ll want to update.

// Set your Table Name
const table = base.getTable("Leads");

// Set your Base64 encoded Clearbit API Key
const clearbit_key = '<KEY>';

Step 2: Get the record in question

const record = await input.recordAsync('Select a record to use', table);

Step 3: Do the fetch and render

// Call out to Clearbit
var myHeaders = new Headers();
myHeaders.append("Authorization", `Basic ${clearbit_key}`);

var requestOptions = {
    method: 'GET',
    headers: myHeaders,
    redirect: 'follow'
};

await fetch(`https://person.clearbit.com/v2/combined/find?email=${record.getCellValueAsString("Email")}`, requestOptions)
.then(response => response.text())
.then(result => {
    const clearbitRes = JSON.parse(result);

    // Show available information
    output.inspect(clearbitRes);
    
    table.updateRecordAsync(record, {
        "Name": clearbitRes.person.name.fullName,
        "Company": clearbitRes.company.name,
        "Job Title": clearbitRes.person.employment.title,
        "LinkedIn": clearbitRes.person.linkedin.handle ? `https://linkedin.com/${clearbitRes.person.linkedin.handle}` : ``,
    });
})
.catch(error => output.text(error));

This was a really fun project and the script compiler made things super easy to check my work.

13 Likes

After mulling over which script to choose for this contest for far too long, I opted for something simple yet powerful.

In this example, clicking the {Monthly Summary} button on a record on the [Monthly] table will produce a simple report of all transactions which fall under that month. The use-case I’m presenting is a simple home and budget tracker, but this could be applied to myriad industries/topics.

Prior to producing the report, the script first searches across the [Income] and [Expenses] tables to find any records which are dated within that month, and link them to the month chosen. I added this step to address a problem that I often run into when training Airtable users: it is super easy for a base to get messy and unorganized if the user doesn’t understand the linking relationships. This step ensures that no record falls under the radar and is properly accounted for when it’s time for reporting.

Bonus of this script: it converts numeric values to currencies so they are displayed nicely in the report :slight_smile:

Video:

Script:

Step 1: Define variables/functions input.recordAsync call
The functions under this step group transactions by category and convert numeric values to currencies

//define our tables
let income = base.getTable("Income");
let expenses = base.getTable("Expenses");
let monthly = base.getTable("Monthly");
let yearly = base.getTable("Yearly");

//define function to convert numbers to currencies for report display
function toCurrency(rawValue) {
    return "$ " + (Number(rawValue)).toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
}

//function to group amounts by categories
function countByField(records, groupByField) {
    let counts = {};
    let typeArr = [];
    for (let record of records) {
        let key = record.getCellValueAsString(groupByField);
        if (key in counts) {
            counts[key] = counts[key] + Number(record.getCellValue("Amount"));
        } else {
            counts[key] = Number(record.getCellValue("Amount"));
        }
        //push keys to their own array to facilitate currency format
        if (typeArr.indexOf(key) === -1) {
            typeArr.push(key);
        }
    }
        //format numbers as currencies
        for (var i = 0; i < typeArr.length; i++) {
            if (!isNaN(counts[typeArr[i]])) {
                counts[typeArr[i]] = toCurrency(counts[typeArr[i]]);
            }
        }
    return counts;
}

let today = new Date();

output.markdown("# Monthly Summary");

//pick month for report
let reportMonth = await input.recordAsync("Select Month", monthly);

Step 2: Find and update records that should be linked to month selected

//first check that no records are missing from monthly bucket
//iterate through expense and income tables to find missing records
let tables = [income, expenses];
for (var n = 0; n < tables.length; n++) {

    let tableRecords = await tables[n].selectRecordsAsync();
    let recordUpdates = [];
    let summary = tables[n].getField("Monthly Summary");

    output.markdown("#### Updating " + tables[n].name + " Records...");
    for (let record of tableRecords.records) {
        let recordMonth = record.getCellValue("Month");
        if (reportMonth.name == recordMonth) {
            recordUpdates.push({
                record,
                after: reportMonth.id
            });
        }
    }

    let updates = recordUpdates.map(update => ({
        id:  update.record.id,
        fields: {
            [summary.id]: [{id: update.after}],
        }
    }));

    // Only up to 50 updates are allowed at one time, so do it in batches
    while (updates.length > 0) {
        await tables[n].updateRecordsAsync(updates.slice(0, 50));
        updates = updates.slice(50);
    }
}

Step 3: Re-select month record to get newly updated totals

//re-select reportMonth record to get newly updated totals
let monthlyUpdated = await monthly.selectRecordsAsync();
let updatedMonth = monthlyUpdated.records.filter(record => record.getCellValueAsString("Name") === reportMonth.name)

Step 4: Select all income and expense records sorted by date, filter to only include month chosen

//select income and expense records
let allIncome = await income.selectRecordsAsync({
    sorts: [
        {field: "Date"}
    ]
});

let allExpenses = await expenses.selectRecordsAsync({
    sorts: [
        {field: "Date"}
    ]
});

//filter income and expense records to include only month chosen
let incomeRecords = allIncome.records.filter(record => record.getCellValueAsString("Monthly Summary") === reportMonth.name);
let expenseRecords = allExpenses.records.filter(record => record.getCellValueAsString("Monthly Summary") === reportMonth.name);

Step 5: Output report

//start report output
output.clear();
output.markdown("# " + reportMonth.name + " Summary");
output.markdown("##### As of " + today);
//output table of gross income and expenses
output.markdown("### Monthly Totals");

//define totals for month
let monthlyTotals = {};
monthlyTotals["Gross Income"] = toCurrency(updatedMonth[0].getCellValue("Gross Income"));
monthlyTotals["Gross Expenses"] = toCurrency(updatedMonth[0].getCellValue("Gross Expenses"));
monthlyTotals["Net Income"] = toCurrency(updatedMonth[0].getCellValue("Net Income"));

//output totals
output.table(monthlyTotals);

//output income summary
output.markdown("### " + reportMonth.name + " Income");

//output income by category
let incomeCategories = countByField(incomeRecords, "Type");

output.markdown("#### Income by Type");
output.table(incomeCategories);

//output all income records
output.markdown("#### All Income");
output.table(
    incomeRecords.map(record => ({
        "Paid From": record.getCellValueAsString("Paid From"),
        "Date": record.getCellValue("Date"),
        "Amount": toCurrency(record.getCellValue("Amount")),
        "Status": record.getCellValue("Status"),
        "Type": record.getCellValueAsString("Type")
    })),
);

//output expense summary
output.markdown("### " + reportMonth.name + " Expenses");

//output expenses by category
let expenseCategories = countByField(expenseRecords, "Expense Type");

output.markdown("#### Expenses by Type");
output.table(expenseCategories);

//output all expenses
output.markdown("#### All Expenses");
output.table(
    expenseRecords.map(record => ({
        "Paid To": record.getCellValueAsString("Vendor"),
        "Date": record.getCellValue("Date"),
        'Amount': toCurrency(record.getCellValue("Amount")),
        "Status": record.getCellValue("Status"),
        "Type": record.getCellValueAsString("Expense Type")
    })),
);

Looking forward to seeing all the awesome stuff everyone is doing with these new goodies :slight_smile:

9 Likes

Hi all
This is my entry for the Scripting Contest.Exicted to share with the community.
I have built three button that helps some one to keep track off their stock portolio every day. This button will helps me to solve my Problem so that I won’t need to update the current market price value of the Stocks.
Compute Button get you the Current Price of the Stocks
Buy Button helps you to update the Stocks whenever you buy more
Sell Button helps you to update the number of units that have sold and will calcuate the Average Share Price per unit as well

https://www.loom.com/share/c6300684934a4aab838b4d379fc25d4f

Script for my compute button

const table = base.getTable("Networth Calculator");
const apiKey = "<APIKEY>";
let record = await input.recordAsync("Select a Record" ,table);
let response = await fetch(`https://finnhub.io/api/v1/quote?symbol=${record.getCellValueAsString("Symbol")}&token=${apiKey}`);
let data = await response.json();
table.updateRecordAsync(record,{
    "CurrentPrice" : data.pc
});

Script for my Buy Button

let table = base.getTable('Networth Calculator');
let record = await input.recordAsync('Select a Record', table);
let unit = await input.textAsync('How Many Units did you buy today ');
let price = await input.textAsync('Price Unit for the Each Stocks');

if(record && unit && price){
    table.updateRecordAsync(record,{
        'Units' : record.getCellValue('Units')
            + parseFloat(unit),
        'Average Price(Per Unit )' : ((record.getCellValue('Units') * record.getCellValue('Average Price(Per Unit )')) + (parseFloat(unit) * parseFloat(price)))/(record.getCellValue('Units') + parseFloat(unit))
    })
}

Script for the Sell Button


let table = base.getTable('Networth Calculator');
let record = await input.recordAsync('Select a Record', table);
let unit = await input.textAsync('How Many Units did you Sell today ');
let price = await input.textAsync('Price Unit that you Sell For Each Stocks');

if(record && unit && price){
    table.updateRecordAsync(record,{
        'Units' : record.getCellValue('Units')
            - parseFloat(unit),
        'Average Price(Per Unit )' : record.getCellValue('Units')
            - parseFloat(unit)?((record.getCellValue('Units') * record.getCellValue('Average Price(Per Unit )')) - (parseFloat(unit) * parseFloat(price)))/(record.getCellValue('Units') - parseFloat(unit)) : 0
    })
}

Combination of Button and Script Query Helps me to automate the tracking of the portfolio

3 Likes

Hi everyone,

Here is my participation for the scripting contest using newly launched button field. Three buttons are used in my demonstration: « present », « absent » and « fix email ».

You can see this in action in the following video: https://www.loom.com/share/37ce5f7d65a142a5b7d50cc9cfcb0ad3

Script for my « present » button:

let event = base.getTable('Event');
let employee = base.getTable('Employee');
output.markdown('## Present');
let eventRecord = await input.recordAsync('Pick an event:', event);
if (eventRecord) {
    output.text(`You chose: ${eventRecord.getCellValueAsString("Name")}!`);
}
let employeeRecord = await input.recordAsync('Pick your name:', employee);
if (employeeRecord) {
    employee.updateRecordAsync(employeeRecord, {
        'Answer': {name: "Present"}
    })
    output.text(`${employeeRecord.getCellValueAsString("Name")}, your answer is updated!`);
}

Script for my « absent » button:

let event = base.getTable('Event');
let employee = base.getTable('Employee');
output.markdown('## Absent');
let eventRecord = await input.recordAsync('Pick an event:', event);
if (eventRecord) {
    output.text(`You chose: ${eventRecord.getCellValueAsString("Name")}!`);
}
let employeeRecord = await input.recordAsync('Pick your name:', employee);
if (employeeRecord) {
    employee.updateRecordAsync(employeeRecord, {
        'Answer': {name: "Absent"}
    })
    output.text(`${employeeRecord.getCellValueAsString("Name")}, your answer is updated!`);
}

Script for my « fix email » button:

let table = base.getTable("Employee");
let field = table.getField("Email");
output.markdown('## Fix Email');
let findText = await input.textAsync('Enter text to find:');
let replaceText = await input.textAsync('Enter to replace matches with:');
let result = await table.selectRecordsAsync();
let replacements = [];
for (let record of result.records) {
    let originalValue = record.getCellValue(field);
    if (!originalValue) {
        continue;
    }
    let newValue = originalValue.replace(findText, replaceText);
    if (originalValue !== newValue) {
        replacements.push({
            record,
            before: originalValue,
            after: newValue,
        });
    }
}
if (!replacements.length) {
    output.text('No replacements found');
} else {
    output.markdown('## Replacements');
    output.table(replacements);
    let shouldReplace = await input.buttonsAsync('Are you sure you want to save these changes?', [
        {label: 'Save'},
        {label: 'Cancel'},
    ]);
    if (shouldReplace === 'Save') {
        let updates = replacements.map(replacement => ({
            id: replacement.record.id,
            fields: {
                [field.id]: replacement.after,
            }
        }));
        while (updates.length > 0) {
            await table.updateRecordsAsync(updates.slice(0, 10));
            updates = updates.slice(10);
        }
    }
}

Thank you for your attention!

2 Likes

For the button scripting contest, I am submitting a script that copies a value from one field to another field. This is most useful when you want to “lock in” the current value of a computed field.

This script is designed so that it can be used as-is or developers can pick out the individual functions that they want to use in their larger scripts.

Example use cases:

  • A computed field shows the price of an item in a linked record and you want to lock in the current price when a sale is made.

  • You want to record the last modified time only for a particular change, not all changes.

  • You want to set the value for a field based on a calculated value, but you also want to be able to change the value manually.

  • You pull in data from a form or integration, and you want a person to decide which fields to copy on a record-by-record basis.

  • You want to copy data from field to field on a record-by-record basis with one click, even if one of the fields isn’t in the current view.

Here is the video:

Here is the script:

// edit the table and field names to match your base
const tableName = "Table 1";
const sourceFieldName = "Formula";
const targetFieldName = "targetField";

// get the table and record
const table = base.getTable(tableName);
const record = await input.recordAsync("Pick a record", table);
// change the function name below for your desired field types
const writeValue = await copyToStringField(table, record, sourceFieldName, targetFieldName);
output.markdown(`Copied value of **${sourceFieldName}** to **${targetFieldName}**`);
output.inspect(writeValue);


/*******************************************************************************
Functions for copying cell value depending on field types
These functions return the value that was written.
*******************************************************************************
*/

// use if both fields use the same basic data type (string based, number based, dateTime, etc.)
// note that fields can be different field types as long as the data type is the same
async function copySameFieldTypes(table, record, sourceFieldName, targetFieldName) {
  // the read and write formats are the same
  const writeValue = record.getCellValue(sourceFieldName);
  await table.updateRecordAsync(record, {[targetFieldName]: writeValue});
  return writeValue;
}


// use if the target field is a string (singleLineText, multilineText, richText, email, phone, url, etc)
async function copyToStringField(table, record, sourceFieldName, targetFieldName) {
  // Airtable already provides the cell value as a string
  const writeValue = record.getCellValueAsString(sourceFieldName);
  await table.updateRecordAsync(record, {[targetFieldName]: writeValue});
  return writeValue;
}


// use if the source is a string, but the target is a number
async function copyStringToNumberField(table, record, sourceFieldName, targetFieldName) {
  let writeValue = record.getCellValue(sourceFieldName);
  if ((writeValue === null) || (Number(writeValue) === NaN)) {
    // if the string is not a number, make the target field blank
    // this check is necessary to avoid having a null source field end up as zero in the target
    writeValue = null;
  } else {
    // convert the string to a number
    // Note: the string must be an UNFORMATTED number. Values like "1,000" will not work
    writeValue = Number(writeValue);
  }
  await table.updateRecordAsync(record, {[targetFieldName]: writeValue});
  return writeValue;
}


// use if the source is a string, but the target is a date
async function copyStringToDateTimeField(table, record, sourceFieldName, targetFieldName) {
  let writeValue = record.getCellValue(sourceFieldName);
  if (writeValue) {
    let dateTime= new Date(writeValue);
    writeValue = dateTime.toISOString();
  }
  await table.updateRecordAsync(record, {[targetFieldName]: writeValue});
  return writeValue;
}


// use if the target is a singleSelect
async function copyToSingleSelect(table, record, sourceFieldName, targetFieldName) {
  let writeValue = record.getCellValueAsString(sourceFieldName);
  if (writeValue) {
    writeValue = {"name": writeValue};
  } else {
    writeValue = null;
  }
  await table.updateRecordAsync(record, {[targetFieldName]: writeValue});
  return writeValue;
}


// use if the target is a multipeSelects
async function copyToMultipleSelects(table, record, sourceFieldName, targetFieldName) {
  let writeValue = record.getCellValueAsString(sourceFieldName);
  if (writeValue) {
    writeValue = writeValue.split(", ");
    writeValue = writeValue.map(value => {return {"name": value}});
  } else {
    writeValue = null;
  }
  await table.updateRecordAsync(record, {[targetFieldName]: writeValue});
  return writeValue;
}
4 Likes

@JonathanBowen and @thilak_vasu_deva,

Your buttons have already helped improve process compliance with my group. Thank you!!

Thank you Airtable for adding such a simple and useful feature!!
(Simple from an end-user perspective)

7 Likes

@thilak_vasu_deva, is there a way to track and/or show a list of all the “BUYS” and “SELLS” submitted using the Buy and Sell button?

Ooh, I’d love to get Thilak’s take, but maybe after the update, we could simply use createRecordAsync to create a new record in a “transactions” type table that holds all of the buy/sell commands that have been ran!

2 Likes

Yeah you can do this by having a new table ‘Transaction’ and you can create new record when ever you buy or sell.

I have included my screenshot for the table ‘Transaction’ that will hold all the detail of all the transaction

Updated Code For Buy Button

let table = base.getTable('Networth Calculator');
let trasactionTable = base.getTable('Transaction');
let record = await input.recordAsync('Select a Record', table);
let unit = await input.textAsync('How Many Units did you buy today ');
let price = await input.textAsync('Price Unit for the Each Stocks');

if(record && unit && price){
    table.updateRecordAsync(record,{
        'Units' : record.getCellValue('Units')
            + parseFloat(unit),
        'Average Price(Per Unit )' : ((record.getCellValue('Units') * record.getCellValue('Average Price(Per Unit )')) + (parseFloat(unit) * parseFloat(price)))/(record.getCellValue('Units') + parseFloat(unit))
    });

    trasactionTable.createRecordAsync({
        "Stocks" : record.getCellValue('Name'),
        "Price" : parseFloat(price),
        "Units" : parseFloat(unit),
        "Symbol" : record.getCellValue('Symbol'),
        "Status" : "Buy"
    })
}

Update code for Sell Button

let table = base.getTable('Networth Calculator');
let trasactionTable = base.getTable('Transaction');
let record = await input.recordAsync('Select a Record', table);
let unit = await input.textAsync('How Many Units did you Sell today ');
let price = await input.textAsync('Price Unit that you Sell For Each Stocks');

if(record && unit && price){
    table.updateRecordAsync(record,{
        'Units' : record.getCellValue('Units')
            - parseFloat(unit),
        'Average Price(Per Unit )' : record.getCellValue('Units')
            - parseFloat(unit)?((record.getCellValue('Units') * record.getCellValue('Average Price(Per Unit )')) - (parseFloat(unit) * parseFloat(price)))/(record.getCellValue('Units') - parseFloat(unit)) : 0
    })

     trasactionTable.createRecordAsync({
        "Stocks" : record.getCellValue('Name'),
        "Price" : parseFloat(price),
        "Units" : parseFloat(unit),
        "Symbol" : record.getCellValue('Symbol'),
        "Status" : "Sell"
    })
}

Whenever you buy and sell stock it will automatically update in the Transaction table that holds all of your transaction

Hopes this helps you.

3 Likes

I too had the same idea @Shaun_Van_Weelden

Time to announce our winner for this scripting contest!

Before I do, I wanted to say thank you again to everyone who submitted scripts - we just shared them with our whole company today and our team is just blown away by the creativity shown!

As for the winner…

@Neads_Admin takes the cake with her home budget and reporting solution :raised_hands:



Why our team selected this winning script

Generating custom reports is a very common need, and the solution shared here demonstrates a simple yet powerful way to generate just-in-time reports based off of a record. It was additionally impressive in how it does automatic clean up of the data before generating the report (in case of user error), which would save the a lot of time for anyone running this script who is prone to making a mistake or two (who isn’t!?). Lastly, the report is well structured, easy to read, and provides immediate value for anyone looking for this type of reporting for both personal and business use.

Great job @Neads_Admin!


Bonus for everyone who contributed :tada:

To say thanks for everyone else who submitted scripts for this contest, we’re going to send over 3 months worth of Airtable credits ($72) for your account @JonathanBowen @Shaun_Van_Weelden @thilak_vasu_deva @Dhack_Odac @kuovonne.

20 Likes

Congrats, @Neads_Admin! Nice work!

2 Likes

Great choice!! Great work @Neads_Admin!!

2 Likes

Well done @Neads_Admin!! Lots of quality entries in this competition - great job for taking the top spot :slight_smile:

2 Likes

Wonderful functionality @Neads_Admin. Congrats!

2 Likes

Oh my goodness I can’t express in words how excited I am :slight_smile: There were so many awesome submissions, and I can’t wait to see what everyone continues to come up with!

THANK YOU SO MUCH to @Jason and the whole Airtable team - and of course, everyone here for the kind words!

EDIT: For anyone interested… here is a link to the base!

7 Likes

Ok, all of this, for me, at my age, is like trying to muddle my way through learning to speak Russian.

That said, as a VERY proud aunt of @Neads_Admin, I just wanted to congratulate her on her win!

She helps me everyday, and never scoffs at me to asking ridiculously stupid 50-year-old questions about Excel or Photoshop.

I know I’m biased but I wish I had a fraction of her skill, then I could stop bugging her! :grin:

7 Likes