Skip to content

Instantly share code, notes, and snippets.

@justinsbarrett
Last active March 3, 2024 04:31
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save justinsbarrett/981f2699acfdafe024b6d01c9496a8d1 to your computer and use it in GitHub Desktop.
Save justinsbarrett/981f2699acfdafe024b6d01c9496a8d1 to your computer and use it in GitHub Desktop.
Creates a series of labels based on a hierarchy of records in a single table, with the hierarchy determined by linking records.
/**
* Title: Recursive Hierarchy Labels
* License: MIT
* Author: Justin Barrett
* Sites:
* http://www.allaboutthatbase.tips - Main website
* https://www.youtube.com/c/AllAboutThatBase1 - All About That Base (YouTube channel)
* Show your support: https://ko-fi.com/allaboutthatbase
*
* Revision history:
* 1.0 2021-11-03 Initial release
* 1.1 2021-12-03 Added support for a user-selectable label field instead of always using the record name
* 1.2 2022-08-16 Optimized. Added support for parent-to-child linking.
* 1.3 2022-09-26 Fixed a bug that propagated incorrect labels to children.
*
* Source: https://gist.github.com/justinsbarrett/981f2699acfdafe024b6d01c9496a8d1
*
*/
const settings = input.config({
title: "Recursive Hierarchy Labels",
description: `When tracking items that are part of a hierarchy, it's often
desirable to have an ID (1.2.1.1) or path (Paul->Peter->Mary) that outlines
the hierarchy tree. This script parses records in a single table to build
hierarchy labels, with the hierarchy established by record links. Labels
may be built in either direction: parent-to-child or child-to-parent.`,
items: [
input.config.table("table", {
label: "Table",
description: "The table where the hierarchy is established"
}),
input.config.view("view", {
label: "View",
description: "Because record order is important for this operation, and only views can be used to specify such order, a view is required.",
parentTable: "table"
}),
input.config.field("linkField", {
label: "Link field",
description: "The field linking to other table records",
parentTable: "table"
}),
input.config.select("direction", {
label: "Parse direction",
description: `Which direction should be used to parse the links.
"Parent-to-Child" is used when each parent record is linked to one or more child records.
"Child-to-Parent" is used when each child record is linked to a single parent.`,
options: [
{value: "Parent-to-Child"},
{value: "Child-to-Parent"}
]
}),
input.config.select("mode", {
label: "Mode",
description: `How should the labels be built?
"Index" uses a numerical index based on the hierarchy depth of each item (e.g. 1.2.1.1).
"Path" uses the value from the "Label field" option selected below (e.g. Paul>Peter>Mary).`,
options: [
{value: "Index"},
{value: "Path"}
]
}),
input.config.field("labelField", {
label: "Label field",
description: `The field containing the label to use for each item. This could be the primary field, or some other field if desired.
NOTE: If you choose the "Index" mode above, this setting will be ignored.`,
parentTable: "table"
}),
input.config.text("separator", {
label: "Separator",
description: "The separator between items. Be sure to include spaces around the separator if desired."
}),
input.config.field("outputField", {
label: "Output field",
description: "The field (single line or long text) where the script will create the item labels",
parentTable: "table"
}),
]
})
const { table, view, linkField, mode, labelField, separator, outputField, direction } = settings
// Collect the records
const query = await view.selectRecordsAsync({
fields: [linkField, labelField, outputField]
})
let updates = []
const parse_childToParent = (parent=null, parentLabel="") => {
// Find all records with the specified parent
let matches = query.records.filter(record => {
let parentLink = record.getCellValue(linkField)
if (!parent)
return !parentLink
else if (parentLink)
return record.getCellValue(linkField)[0].id == parent
return false
})
// If we haven't found any records, we're done with this level
if (!matches.length)
return
// Set the record's label, adding its parent's label (if any)
matches.forEach((match, index) => {
let itemLabel = mode === "Index" ? String(index + 1) : match.getCellValueAsString(labelField)
let fullLabel = !parentLabel ? itemLabel : parentLabel + itemLabel
updates.push({
id: match.id,
fields: {
[outputField.name]: fullLabel
}
})
// Make a recursive call to find and label children of this item
parse_childToParent(match.id, fullLabel + separator)
})
}
const parse_parentToChild = () => {
const parseChildren = (childId, childIndex, labels=[]) => {
// Get the child record and its label
const childRecord = query.getRecord(childId)
const childLabel = mode === "Index" ? (childIndex + 1).toString() : childRecord.getCellValue(labelField);
labels.push(childLabel)
updates.push({
id: childRecord.id,
fields: {[outputField.name]: labels.join(separator)}
});
// Parse children
(childRecord.getCellValue(linkField) || []).map(link => link.id).forEach((id, index) => parseChildren(id, index, [...labels]))
}
// Find root records
const roots = query.records.filter(rootRecord => {
const parents = query.records.filter(rec => {
return (rec.getCellValue(linkField) || []).map(link => link.id).includes(rootRecord.id)
})
return parents.length === 0
})
// Parse branches based on roots
roots.forEach((rootRecord, index) => {
const rootLabel = mode === "Index" ? (index + 1).toString() : rootRecord.getCellValue(labelField);
(rootRecord.getCellValue(linkField) || []).map(link => link.id).forEach((id, childIndex) => parseChildren(id, childIndex, [rootLabel]))
updates.push({
id: rootRecord.id,
fields: {[outputField.name]: rootLabel}
})
})
}
output.markdown("# Building hierarchy labels")
output.markdown(`Mode: **${mode}**`);
output.markdown(`Direction: **${direction}**`);
switch(settings.direction) {
case "Child-to-Parent":
parse_childToParent();
break
case "Parent-to-Child":
parse_parentToChild();
break
}
// Save all labels to the specified field
if (updates.length) {
output.markdown("---")
output.markdown(`## Hierarchy parsing complete.`)
output.text(`Updating ${updates.length} records...`)
while (updates.length)
await table.updateRecordsAsync(updates.splice(0, 50))
output.markdown("---")
output.markdown("## Update complete")
} else {
output.text("No changes to apply")
}
@justinsbarrett
Copy link
Author

Updated 12/03/2021. Added support for a user-selectable label field instead of always using the record name

@justinsbarrett
Copy link
Author

Updated 08/13/2022. Added support for parent-to-child link parsing.

@Julian-Dumitrascu
Copy link

Where can one see e.g. a video of the result?

@justinsbarrett
Copy link
Author

Where can one see e.g. a video of the result?

I'm afraid I haven't recorded a demo video. It may happen eventually, but it's pretty low on my priority list at the moment. To see an example (not video, just a screenshot), check this thread from the community:

https://community.airtable.com/t5/other-questions/recursive-hierarchy-in-self-linked-table-using-quot-child-quot/td-p/76609

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment