From 861235f7a7e5d2e5ef53501bc334e9b0f6e1e725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C5=A9=20=C4=90=E1=BB=A9c=20Duy?= Date: Sun, 30 Apr 2023 11:40:59 +0700 Subject: [PATCH 1/3] feat: allow parsing literal string in template --- src/operations.test.ts | 83 ++++++++++++++++++++++++++++++++++++++++++ src/operations.ts | 29 +++++++++++---- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/operations.test.ts b/src/operations.test.ts index a5691643..79d3f665 100644 --- a/src/operations.test.ts +++ b/src/operations.test.ts @@ -272,6 +272,64 @@ describe('Test parseExpression', () => { expect(parseExpression('IF(EQUAL(a, 5), b, c)', { a: 5, b: 1, c: 2})).toBe(1); expect(parseExpression('IF(AND(GT(a, 0), LT(a, 10)), b, c)', { a: 5, b: 1, c: 2})).toBe(1); }); + + describe('Nested expressions', () => { + test('Simple nested numeric expression', () => { + expect(parseExpression('SUM(a, MULTIPLY(b, c))', { a: 1, b: 2, c: 3 })).toBe(7); + }); + + test('Complex nested numeric expression', () => { + expect(parseExpression('SUM(a, MULTIPLY(b, SUM(c, d)))', { a: 1, b: 2, c: 3, d: 4 })).toBe(15); + }); + + test('Simple nested boolean expression', () => { + expect(parseExpression('AND(a, OR(b, c))', { a: true, b: false, c: false })).toBe(false); + }); + + test('Complex nested boolean expression', () => { + expect(parseExpression('AND(a, OR(b, AND(c, d)))', { a: true, b: false, c: true, d: false })).toBe(false); + }); + + test('Simple nested string expression', () => { + expect(parseExpression('CONCAT(a, CONCAT(b, c))', { a: 'a', b: 'b', c: 'c' })).toBe('abc'); + }); + + test('Complex nested string expression', () => { + expect(parseExpression('CONCAT(a, CONCAT(b, CONCAT(c, d)))', { a: 'a', b: 'b', c: 'c', d: 'd' })).toBe('abcd'); + }); + }); + + describe('Literal strings', () => { + test('Simple string', () => { + expect(parseExpression('"a"', {})).toBe('a'); + }); + + test('String with escaped quotes', () => { + expect(parseExpression('"a\\"b"', {})).toBe('a"b'); + }); + + test('String with escaped backslash', () => { + expect(parseExpression('"a\\b"', {})).toBe('a\\b'); + }); + + test('String with parentheses and comma', () => { + expect(parseExpression('"a(b,c)d"', {})).toBe('a(b,c)d'); + }); + + test('String with all special characters', () => { + expect(parseExpression('"a(b,c)d\\"e\\f"', {})).toBe('a(b,c)d"e\\f'); + }); + + test('String operator 1', () => { + expect(parseExpression('RIGHT(CONCAT(UPPER(CONCAT(a, "c")), 1), 3)', { a: 'ab' })).toBe('BC1'); + }); + + test('String operator 2', () => { + expect(parseExpression('EQUAL(CONCAT(LOWER("A,()\\""), a), "a,()\\"bc")', { a: 'bc' })).toBe(true); + expect(parseExpression('EQUAL(CONCAT("A,()\\"", a), "a,()\\"bc")', { a: 'bc' })).toBe(false); + expect(parseExpression('EQUAL(CONCAT("A,()\\"", a), "A,()\\"bc")', { a: 'bc' })).toBe(true); + }); + }); }); describe('Test parseOp', () => { @@ -345,6 +403,31 @@ describe('Test parseOp', () => { args: ['OP_(var1)', 'var2', 'var3'], }); }); + + test('Contains space at both ends', () => { + expect(parseOp(' OP_(var1) ')).toStrictEqual({ + op: 'OP_', + args: ['var1'], + }); + }); + + test('Handle literal string', () => { + expect(parseOp('OP_("(abc)\\", \\"(def)", ")(,\\"")')).toStrictEqual({ + op: 'OP_', + args: ['"(abc)\\", \\"(def)"', '")(,\\""'], + }); + }); + + test('Handle literal string in complex op', () => { + expect(parseOp('OP_(OP_(var1), OP_("(abc)\\", \\"(def)"))')).toStrictEqual({ + op: 'OP_', + args: ['OP_(var1)', 'OP_("(abc)\\", \\"(def)")'], + }); + expect(parseOp('OP_(OP_("(abc)\\", \\"(def)"), OP_(var1))')).toStrictEqual({ + op: 'OP_', + args: ['OP_("(abc)\\", \\"(def)")', 'OP_(var1)'], + }); + }); }); describe('Test toSlug', () => { diff --git a/src/operations.ts b/src/operations.ts index 85ac0f00..d388eb79 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -4,6 +4,11 @@ export function parseExpression(exp: string, values: Record, defaul if (values) { exp = exp.trim(); + // literal string + if (exp.startsWith('"') && exp.endsWith('"')) { + return exp.slice(1, -1).replace(/\\"/g, '"'); + } + let { value, found } = findValueByPath(values, exp); if(!found || value === null) { @@ -222,24 +227,34 @@ export function parseOp(exp: string): { op: string; args: string[]; } | null { - const match = exp.match(/^([A-Z_]+)\((.+)\)$/); + const match = exp.trim().match(/^([A-Z_]+)\((.+)\)$/); if (match) { const args = []; const op = match[1] as string; const innerExp = match[2] as string; - let braceCount = 0, i = 0, j = 0; + let braceCount = 0, + i = 0, + j = 0, + inQuote = false, + escapeNext = false; for (; i < innerExp.length; i += 1) { const c = innerExp[i]; - if (c === '(') braceCount += 1; - if (c === ')') braceCount -= 1; - if (c === ',' && braceCount === 0) { - args.push(innerExp.slice(j, i)); + if (c === '(' && !inQuote) braceCount += 1; + else if (c === ')' && !inQuote) braceCount -= 1; + else if (c === ',' && !inQuote && braceCount === 0) { + args.push(innerExp.slice(j, i).trim()); j = i + 1; } + else if (c === '"' && !escapeNext) inQuote = !inQuote; + else if (c === '\\' && inQuote) { + escapeNext = true; + continue; + } + escapeNext = false; } if (j < i) { - args.push(innerExp.slice(j, i)); + args.push(innerExp.slice(j, i).trim()); } return { op, args }; From e2d5bf1342fc79442ddf18a470289330f3abaa9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C5=A9=20=C4=90=E1=BB=A9c=20Duy?= Date: Sun, 30 Apr 2023 12:20:13 +0700 Subject: [PATCH 2/3] docs: document literal strings & some minor improvements --- README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ecb76075..4da99d66 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ npm i directus-extension-computed-interface # Get Started 1. Go to **Settings**, create a new field with type string or number. -2. In the **Interface** panel, choose **Computed** interface. There are 2 options: +2. In the **Interface** panel, choose **Computed** interface. There are 5 options: 1. **Template**: Similar to M2M interface, determine how the field is calculated. Learn more about syntax in the next section. 2. **Field Mode**: Choose how the value is displayed. - **null**: Default option. Show an input with the computed value but still allow manual editing. @@ -30,9 +30,9 @@ npm i directus-extension-computed-interface # Syntax -The template consists of 2 elements: plain strings & expressions. -- Plain strings are string literal, often used for text interpolation. -- Expressions can contains operators, other fields & numbers. They must be enclosed by `{{` and `}}`. +The template consists of 2 elements: **plain strings** & **expressions**. +- **Plain** strings are string literal, often used for text interpolation. +- **Expressions** can contains operators, field names, numbers & strings. They must be enclosed by `{{` and `}}`. ## Examples Sum 2 numbers: @@ -60,6 +60,11 @@ Complex calculation: {{ SUM(MULTIPLY(2, x), b) }} ``` +Literal strings are enclosed by double quotes (`"`): +``` +{{ CONCAT(file, ".txt") }} +``` + ## Available operators ### Type conversion @@ -119,9 +124,9 @@ Operator | Description `LOWER(a)` | to lower case `UPPER(a)` | to upper case `TRIM(a)` | removes whitespace at the beginning and end of string. -`CONCAT(a, b)` | concat 2 strings -`LEFT(a, b)` | extract `b` characters from the beginning of the string. -`RIGHT(a, b)` | extract `b` characters from the end of the string. +`CONCAT(a, b)` | concat 2 strings `a` and `b`. +`LEFT(a, b)` | extract `b` characters from the beginning of the string `a`. +`RIGHT(a, b)` | extract `b` characters from the end of the string `a`. ### Boolean @@ -162,7 +167,3 @@ Operator | Description There are 2 dynamic variables available that you can use in the expressions: - `$NOW`: return the current Date object. Example: `{{ YEAR($NOW) }}` returns the current year. - `$CURRENT_USER`: return the current user's id. Example: `{{ EQUAL($CURRENT_USER, user) }}` checks if the `user` field is the current user. - - -# Limitation -- Cannot parse literal strings (`{{ 's' }}`). From 71abd01984b9cac727451128a1f607a8eda8a9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C5=A9=20=C4=90=E1=BB=A9c=20Duy?= Date: Sun, 30 Apr 2023 12:22:02 +0700 Subject: [PATCH 3/3] v1.6.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c3b7193..569b86bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "directus-extension-computed-interface", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "directus-extension-computed-interface", - "version": "1.5.0", + "version": "1.6.0", "license": "gpl-3.0", "devDependencies": { "@babel/core": "^7.19.3", diff --git a/package.json b/package.json index 1e4e9239..37938ad0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "directus-extension-computed-interface", - "version": "1.5.0", + "version": "1.6.0", "description": "Perform computed value based on other fields", "author": { "email": "duydvu98@gmail.com",