Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ store
store.js
main.js
extension.js
extension.js.map
analyze.txt
coverage
2 changes: 2 additions & 0 deletions docs/060-alternative-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ By default, the workflow runs any time after midnight each day. If you would lik
You can insert a button into a block so the button will run a SmartBlock workflow when the user clicks on it. To do this, insert a button using the following syntax:

- `{{caption:SmartBlock:workflow name}}`
- If you would like the button to display no caption, simply omit the first parameter:
- `{{:SmartBlock:workflow name}}`
- First, you notice the syntax starts with `{{` and also ends with `}}`
- Three parameters separated by a `:`
1. **Caption** - the name that will appear on the button. (Do not use caption names that conflict with other Roam features, like: table, kanban, test)
Expand Down
46 changes: 15 additions & 31 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { runDaily } from "./utils/scheduleNextDailyRun";
import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid";
import { zCommandOutput } from "./utils/zodTypes";
import { IconNames } from "@blueprintjs/icons";
import parseSmartBlockButton from "./utils/parseSmartBlockButton";

const getLegacy42Setting = (name: string) => {
const settings = Object.fromEntries(
Expand Down Expand Up @@ -542,30 +543,13 @@ export default runExtension(async ({ extensionAPI }) => {
text: string;
el: HTMLElement;
parentUid: string;
hideIcon?: false;
hideIcon?: boolean;
}) => {
// We include textcontent here bc there could be multiple smartblocks in a block
const regex = new RegExp(
`{{(${textContent.replace(/\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}`
);
const match = regex.exec(text);
if (match) {
const {
[1]: buttonContent = "",
[2]: buttonText = "",
index,
[0]: full,
} = match;
const [workflowName, args = ""] = buttonText.split(":");
const variables = Object.fromEntries(
args
.replace(/\[\[[^\]]+\]\]/g, (m) => m.replace(/,/g, "ESCAPE_COMMA"))
.split(",")
.filter((s) => !!s)
.map((v) => v.replace(/ESCAPE_COMMA/g, ",").split("="))
.map(([k, v = ""]) => [k, v])
);
variables["ButtonContent"] = buttonContent;
const label = textContent.trim();
const parsed = parseSmartBlockButton(label, text);
if (parsed) {
const { index, full, buttonContent, workflowName, variables } = parsed;
const clickListener = () => {
const workflows = getCustomWorkflows();
const availableWorkflows = getCleanCustomWorkflows(workflows);
Expand Down Expand Up @@ -728,17 +712,20 @@ export default runExtension(async ({ extensionAPI }) => {
if (!shouldHideIcon) {
let iconElement: HTMLElement | null = null;

const hasTextContent = el.textContent && el.textContent.trim() !== "";

if (iconSetting && isValidBlueprintIcon(iconSetting)) {
iconElement = document.createElement("span");
iconElement.className = `bp3-icon bp3-icon-${iconSetting}`;
iconElement.style.marginRight = "7px";
iconElement.style.marginLeft = "0px";
if (hasTextContent) iconElement.style.margin = "0 7px 0 0";
else iconElement.style.margin = "0";
} else {
// Default lego icon
const img = new Image();
img.src =
"https://raw.githubusercontent.com/RoamJS/smartblocks/main/src/img/lego3blocks.png";
img.style.marginRight = "7px";
if (hasTextContent) img.style.margin = "0 7px 0 0";
else img.style.margin = "0";
img.width = 17;
img.height = 14;
iconElement = img;
Expand All @@ -764,17 +751,14 @@ export default runExtension(async ({ extensionAPI }) => {
tag: "BUTTON",
callback: (b) => {
const parentUid = getBlockUidFromTarget(b);
if (
parentUid &&
!b.hasAttribute("data-roamjs-smartblock-button") &&
b.textContent
) {
if (parentUid && !b.hasAttribute("data-roamjs-smartblock-button")) {
const text = getTextByBlockUid(parentUid);
b.setAttribute("data-roamjs-smartblock-button", "true");

// We include textcontent here bc there could be multiple smartblocks in a block
// TODO: if multiple smartblocks have the same textContent, we need to distinguish them
const unload = registerElAsSmartBlockTrigger({
textContent: b.textContent,
textContent: b.textContent || "",
text,
el: b,
parentUid,
Expand Down
51 changes: 51 additions & 0 deletions src/utils/parseSmartBlockButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export const parseSmartBlockButton = (
label: string,
text: string
):
| {
index: number;
full: string;
buttonContent: string;
buttonText: string;
workflowName: string;
variables: Record<string, string>;
}
| null => {
const trimmedLabel = label.trim();
const buttonRegex = trimmedLabel
? new RegExp(
`{{(${trimmedLabel.replace(/\\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}`
)
: /{{\s*:(?:42)?SmartBlock:(.*?)}}/;
const match = buttonRegex.exec(text);
if (!match) return null;
const index = match.index;
const full = match[0];
const buttonContent = trimmedLabel ? match[1] || "" : "";
const buttonText = trimmedLabel ? match[2] : match[1];
const colonIndex = buttonText.indexOf(":");
const workflowName =
colonIndex > -1 ? buttonText.substring(0, colonIndex) : buttonText;
const args = colonIndex > -1 ? buttonText.substring(colonIndex + 1) : "";
const variables = Object.fromEntries(
args
.replace(/\[\[[^\]]+\]\]|<%[^%]+%>/g, (m) =>
m.replace(/,/g, "ESCAPE_COMMA")
)
.split(",")
.filter((s) => !!s)
.map((v) => v.replace(/ESCAPE_COMMA/g, ",").split("="))
.map(([k, v = ""]) => [k, v])
);
variables["ButtonContent"] = buttonContent;
return {
index,
full,
buttonContent,
buttonText,
workflowName,
variables,
};
};

export default parseSmartBlockButton;
67 changes: 67 additions & 0 deletions tests/buttonParsing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
import parseSmartBlockButton from "../src/utils/parseSmartBlockButton";

test("parses SmartBlock button without label", () => {
const text = "{{:SmartBlock:deleteBlock:Icon=locate}}";
const result = parseSmartBlockButton("", text);
expect(result).toBeTruthy();
expect(result?.index).toBe(0);
expect(result?.full).toBe(text);
expect(result?.buttonContent).toBe("");
expect(result?.buttonText).toBe("deleteBlock:Icon=locate");
expect(result?.variables).toMatchObject({
Icon: "locate",
ButtonContent: "",
});
});

test("parses SmartBlock button with variables", () => {
const text =
"{{randomChild:SmartBlock:randomChild:RemoveButton=false,Order=<%RANDOMNUMBER:1,10%>}}";
const result = parseSmartBlockButton("randomChild", text);
expect(result).toBeTruthy();
expect(result?.index).toBe(0);
expect(result?.full).toBe(text);
expect(result?.buttonContent).toBe("randomChild");
expect(result?.buttonText).toBe(
"randomChild:RemoveButton=false,Order=<%RANDOMNUMBER:1,10%>"
);
expect(result?.variables).toMatchObject({
RemoveButton: "false",
Order: "<%RANDOMNUMBER:1,10%>",
ButtonContent: "randomChild",
});
});

test("parses SmartBlock button with workflow containing spaces", () => {
const text =
"{{Add New Meeting Entry:SmartBlock:1on1 Meeting - Date Select:RemoveButton=false}}";
const result = parseSmartBlockButton("Add New Meeting Entry", text);
expect(result).toBeTruthy();
expect(result?.workflowName).toBe("1on1 Meeting - Date Select");
expect(result?.variables).toMatchObject({
RemoveButton: "false",
ButtonContent: "Add New Meeting Entry",
});
});

test("parses SmartBlock button with sibling directive", () => {
const text =
"{{testSibling:SmartBlock:testSibling:Sibling=next,RemoveButton=false}}";
const result = parseSmartBlockButton("testSibling", text);
expect(result?.variables).toMatchObject({
Sibling: "next",
RemoveButton: "false",
ButtonContent: "testSibling",
});
});

test("parses SmartBlock button for today's entry", () => {
const text = `{{Create Today's Entry:SmartBlock:UserDNPToday:RemoveButton=false}}`;
const result = parseSmartBlockButton("Create Today's Entry", text);
expect(result?.workflowName).toBe("UserDNPToday");
expect(result?.variables).toMatchObject({
RemoveButton: "false",
ButtonContent: "Create Today's Entry",
});
});
Loading