Skip to content

Commit

Permalink
Merge pull request #60 from rezo-labs/feat/more_ops
Browse files Browse the repository at this point in the history
New ops for array & string & json
  • Loading branch information
duydvu authored Sep 10, 2023
2 parents e125f7d + 9e080ad commit 146cd29
Show file tree
Hide file tree
Showing 7 changed files with 414 additions and 22 deletions.
54 changes: 51 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ npm i directus-extension-computed-interface
- **Read Only**: Show an input with the computed value and disallow manual editing.
3. **Prefix**: a string to prefix the computed value.
4. **Suffix**: a string to suffix the computed value.
5. **Custom CSS**: an object for inline style binding. Only works with **Display Only** and **Read Only** mode. You can use this option to customize the appearance of the computed value such as font size, color, etc.
5. **Custom CSS**: a JSON object for inline style binding. Only works with **Display Only** and **Read Only** mode. You can use this option to customize the appearance of the computed value such as font size, color, etc. Example: `{"color": "red", "font-size": "20px"}`.
6. **Debug Mode**: Used for debugging the template. It will show an error message if the template is invalid. It will also log to console the result of each component of the template.
7. **Compute If Empty**: Compute the value if the field is empty. This is useful if you want a value to be computed once such as the created date or a unique ID.
8. **Initial Compute**: Compute the value when opening the form. This is useful if you want to compute a value based on the current date or other dynamic values.
Expand Down Expand Up @@ -68,6 +68,19 @@ Literal strings are enclosed by double quotes (`"`):
{{ CONCAT(file, ".txt") }}
```

Use `.` to access nested fields in M2O or M2M fields:
```
{{ CONCAT(CONCAT(user.first_name, " "), user.last_name) }}
```

Combine `AT`, `FIRST`, `LAST`, `JSON_GET` to access nested fields in O2M or JSON fields:
```
{{ JSON_GET(AT(products, 0), "name") }}
{{ JSON_GET(LAST(products), "price") }}
```

**Note**: For M2O, O2M, M2M fields, you can only access the fields of the direct relation. For example, if you have a `user` field that is a M2O relation to the `users` collection, you can only access the fields of the `users` collection. You cannot access the fields of the `roles` collection even though the `users` collection has a M2O relation to the `roles` collection. On the other hand, JSON fields have no such limitation!

## Available operators

### Type conversion
Expand Down Expand Up @@ -102,6 +115,7 @@ Operator | Description
`MINUTES(a)` | get minutes of a date object, similar to `getMinutes`
`SECONDS(a)` | get seconds of a date object, similar to `getSeconds`
`TIME(a)` | get time of a date object, similar to `getTime`
`LOCALE_STR(a, locale, options)` | transform date or date-like object to string with locale format, `options` is a stringified JSON object. Example: `LOCALE_STR("2023-01-01", "en-US", "{\"weekday\": \"long\", \"year\": \"numeric\", \"month\": \"long\", \"day\": \"numeric\"}")` returns "Sunday, January 1, 2023".

### Arithmetic

Expand Down Expand Up @@ -132,7 +146,11 @@ Operator | Description

Operator | Description
--- | ---
`STR_LEN(str)` | length of string
`STR_LEN(str)` | length of string (deprecated, use `LENGTH` instead)
`LENGTH(str)` | length of string
`FIRST(str)` | first character of string
`LAST(str)` | last character of string
`REVERSE(str)` | reverse string
`LOWER(str)` | to lower case
`UPPER(str)` | to upper case
`TRIM(str)` | removes whitespace at the beginning and end of string.
Expand All @@ -147,6 +165,10 @@ Operator | Description
`SEARCH(str, keyword)` | search `keyword` in `str` and return the position of the first occurrence. Return -1 if not found.
`SEARCH(str, keyword, startAt)` | search `keyword` in `str` and return the position of the first occurrence after `startAt`. Return -1 if not found.
`SUBSTITUTE(str, old, new)` | replace all occurrences of `old` in `str` with `new`.
`AT(str, index)` | get character at `index` of `str`.
`INDEX_OF(str, keyword)` | get the position of the first occurrence of `keyword` in `str`. Return -1 if not found.
`INCLUDES(str, keyword)` | check if `str` contains `keyword`.
`SLICE(str, startAt, endAt)` | extract a part of `str` from `startAt` to `endAt`. `endAt` can be negative. Similar to `slice` method of `String`.

### Boolean

Expand All @@ -168,7 +190,27 @@ Operator | Description

Operator | Description
--- | ---
`ARRAY_LEN(a)` | length of array
`ARRAY_LEN(a)` | length of array (deprecated, use `LENGTH` instead)
`LENGTH(a)` | length of array
`FIRST(a)` | first element of array
`LAST(a)` | last element of array
`REVERSE(a)` | reverse array
`CONCAT(a, b)` | concat 2 arrays `a` and `b`.
`AT(a, index)` | get element at `index` of `a`.
`INDEX_OF(a, element)` | get the position of the first occurrence of `element` in `a`. Return -1 if not found.
`INCLUDES(a, element)` | check if `a` contains `element`.
`SLICE(a, startAt, endAt)` | extract a part of `a` from `startAt` to `endAt`. `endAt` can be negative. Similar to `slice` method of `Array`.
`MAP(a, expression)` | apply `expression` to each element of `a` and return a new array, each element of `a` must be an object. Example: `MAP(products, MULTIPLY(price, quantity))` returns an array of total price of each product.
`FILTER(a, expression)` | filter `a` with `expression` and return a new array, each element of `a` must be an object. Example: `FILTER(products, GT(stock, 0))` returns an array of products that are in stock.
`SORT(a, expression)` | sort `a` with `expression` and return a new array, each element of `a` must be an object. Example: `SORT(products, price)` returns an array of products sorted by price.

### JSON

Operator | Description
--- | ---
`JSON_GET(a, key)` | get value of `key` in JSON object `a`.
`JSON_PARSE(a)` | parse string `a` to JSON object.
`JSON_STRINGIFY(a)` | stringify JSON object `a`.

### Relational

Expand All @@ -190,6 +232,12 @@ Operator | Description
`IF(A, B, C)` | return `B` if `A` is `true`, otherwise `C`
`IFS(A1, B1, A2, B2, ..., An, Bn)` | return `Bi` if `Ai` is the first to be `true`, if none of `Ai` is `true`, return `null`

### Others

Operator | Description
--- | ---
`RANGE(start, end, step)` | create an array of numbers from `start` to `end` with `step` increment/decrement. Example: `RANGE(1, 10, 2)` returns `[1, 3, 5, 7, 9]`.

## Dynamic Variables

There are 2 dynamic variables available that you can use in the expressions:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "directus-extension-computed-interface",
"version": "1.8.2",
"version": "1.9.0",
"description": "Perform computed value based on other fields",
"author": {
"email": "duydvu98@gmail.com",
Expand Down
Binary file modified screenshots/screenshot2.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
212 changes: 212 additions & 0 deletions src/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ describe('Test parseExpression', () => {
test('TIME op', () => {
expect(parseExpression('TIME($NOW)', {})).toBe(new Date().getTime());
});

test('LOCALE_STR op', () => {
expect(parseExpression('LOCALE_STR($NOW, "en-US", "{}")', {})).toBe('1/1/2023, 12:00:00 AM');
expect(parseExpression('LOCALE_STR($NOW, "en-US", "{\\"month\\": \\"long\\"}")', {})).toBe('January');
expect(parseExpression('LOCALE_STR($NOW, "en-US", "{\\"weekday\\": \\"long\\", \\"year\\": \\"numeric\\", \\"month\\": \\"long\\", \\"day\\": \\"numeric\\"}")', {})).toBe('Sunday, January 1, 2023');
});
});

describe('Arithmetic ops', () => {
Expand Down Expand Up @@ -262,6 +268,26 @@ describe('Test parseExpression', () => {
expect(parseExpression('STR_LEN(a)', { a: 1 })).toBe(1);
});

test('LENGTH op', () => {
expect(parseExpression('LENGTH(a)', { a: '123' })).toBe(3);
expect(parseExpression('LENGTH(a)', { a: 1 })).toBe(null);
});

test('FIRST op', () => {
expect(parseExpression('FIRST(a)', { a: '123' })).toBe('1');
expect(parseExpression('FIRST(a)', { a: 1 })).toBe(null);
});

test('LAST op', () => {
expect(parseExpression('LAST(a)', { a: '123' })).toBe('3');
expect(parseExpression('LAST(a)', { a: 1 })).toBe(null);
});

test('REVERSE op', () => {
expect(parseExpression('REVERSE(a)', { a: '123' })).toBe('321');
expect(parseExpression('REVERSE(a)', { a: 1 })).toBe(null);
});

test('LOWER op', () => {
expect(parseExpression('LOWER(a)', { a: 'ABCDEF' })).toBe('abcdef');
});
Expand Down Expand Up @@ -320,13 +346,190 @@ describe('Test parseExpression', () => {
expect(parseExpression('SEARCH(a, "b", 3)', { a: 'abcabc' })).toBe(4);
expect(parseExpression('SEARCH(a, "d")', { a: 'abcabc' })).toBe(-1);
});

test('AT op', () => {
expect(parseExpression('AT(a, 1)', { a: 'abc' })).toBe('b');
expect(parseExpression('AT(a, 1)', { a: 1 })).toBe(null);
});

test('INDEX_OF op', () => {
expect(parseExpression('INDEX_OF(a, "b")', { a: 'abcabc' })).toBe(1);
expect(parseExpression('INDEX_OF(a, "c")', { a: 'abcabc' })).toBe(2);
expect(parseExpression('INDEX_OF(a, "d")', { a: 'abcabc' })).toBe(-1);
expect(parseExpression('INDEX_OF(a, "b")', { a: 1 })).toBe(null);
});

test('INCLUDES op', () => {
expect(parseExpression('INCLUDES(a, "b")', { a: 'abcabc' })).toBe(true);
expect(parseExpression('INCLUDES(a, "d")', { a: 'abcabc' })).toBe(false);
expect(parseExpression('INCLUDES(a, "b")', { a: 1 })).toBe(null);
});

test('SLICE op', () => {
expect(parseExpression('SLICE(a, 1, 2)', { a: 'abcdef' })).toBe('b');
expect(parseExpression('SLICE(a, 1, -1)', { a: 'abcdef' })).toBe('bcde');
});
});

describe('Array ops', () => {
test('ARRAY_LEN op', () => {
expect(parseExpression('ARRAY_LEN(a)', { a: [1, 2, 3] })).toBe(3);
expect(parseExpression('ARRAY_LEN(a)', { a: 1 })).toBe(0);
});

test('LENGTH op', () => {
expect(parseExpression('LENGTH(a)', { a: [1, 2, 3] })).toBe(3);
expect(parseExpression('LENGTH(a)', { a: 1 })).toBe(null);
});

test('FIRST op', () => {
expect(parseExpression('FIRST(a)', { a: [1, 2, 3] })).toBe(1);
expect(parseExpression('FIRST(a)', { a: 1 })).toBe(null);
});

test('LAST op', () => {
expect(parseExpression('LAST(a)', { a: [1, 2, 3] })).toBe(3);
expect(parseExpression('LAST(a)', { a: 1 })).toBe(null);
});

test('REVERSE op', () => {
expect(parseExpression('REVERSE(a)', { a: [1, 2, 3] })).toEqual([3, 2, 1]);
expect(parseExpression('REVERSE(a)', { a: 1 })).toBe(null);
});

test('CONCAT op', () => {
expect(parseExpression('CONCAT(a, b)', { a: [1, 2], b: [3, 4] })).toEqual([1, 2, 3, 4]);
expect(parseExpression('CONCAT(a, b)', { a: [1, 2], b: 3 })).toEqual([1, 2, 3]);
});

test('AT op', () => {
expect(parseExpression('AT(a, 1)', { a: [1, 2, 3] })).toBe(2);
expect(parseExpression('AT(a, 1)', { a: 1 })).toBe(null);
});

test('INDEX_OF op', () => {
expect(parseExpression('INDEX_OF(a, 1)', { a: [1, 2, 3] })).toBe(0);
expect(parseExpression('INDEX_OF(a, 2)', { a: [1, 2, 3] })).toBe(1);
expect(parseExpression('INDEX_OF(a, 3)', { a: [1, 2, 3] })).toBe(2);
expect(parseExpression('INDEX_OF(a, 4)', { a: [1, 2, 3] })).toBe(-1);
expect(parseExpression('INDEX_OF(a, 2)', { a: 1 })).toBe(null);
});

test('INCLUDES op', () => {
expect(parseExpression('INCLUDES(a, 1)', { a: [1, 2, 3] })).toBe(true);
expect(parseExpression('INCLUDES(a, 4)', { a: [1, 2, 3] })).toBe(false);
expect(parseExpression('INCLUDES(a, 2)', { a: 1 })).toBe(null);
});

test('SLICE op', () => {
expect(parseExpression('SLICE(a, 1, 2)', { a: [1, 2, 3, 4] })).toEqual([2]);
expect(parseExpression('SLICE(a, 1, -1)', { a: [1, 2, 3, 4] })).toEqual([2, 3]);
});

test('MAP op', () => {
const arr = [
{
a: 1,
b: 'x',
},
{
a: 2,
b: 'y',
},
{
a: 3,
b: 'z',
},
];
expect(parseExpression('MAP(arr, SUM(a, 1))', { arr })).toEqual([2, 3, 4]);
expect(parseExpression('MAP(arr, CONCAT(b, "a"))', { arr })).toEqual(['xa', 'ya', 'za']);
expect(parseExpression('MAP(arr, REPT(b, a))', { arr })).toEqual(['x', 'yy', 'zzz']);

const arr2 = [
{
a: {
b: 1,
},
c: ['x'],
},
{
a: {
b: 2,
},
c: ['y'],
},
{
a: {
b: 3,
},
c: ['z'],
},
];

expect(parseExpression('MAP(arr2, SUM(a.b, 1))', { arr2 })).toEqual([2, 3, 4]);
expect(parseExpression('MAP(arr2, CONCAT(FIRST(c), "a"))', { arr2 })).toEqual(['xa', 'ya', 'za']);
expect(parseExpression('MAP(arr2, REPT(FIRST(c), a.b))', { arr2 })).toEqual(['x', 'yy', 'zzz']);
});

test('FILTER op', () => {
const arr = [
{
a: 1,
b: 'x',
},
{
a: 2,
b: 'y',
},
{
a: 3,
b: 'z',
},
];
expect(parseExpression('FILTER(arr, EQUAL(a, 1))', { arr })).toEqual([arr[0]]);
expect(parseExpression('FILTER(arr, EQUAL(b, "y"))', { arr })).toEqual([arr[1]]);
expect(parseExpression('FILTER(arr, LT(a, 3))', { arr })).toEqual([arr[0], arr[1]]);
});

test('SORT op', () => {
const arr = [
{
a: 2,
b: 'y',
},
{
a: 1,
b: 'x',
},
{
a: 3,
b: 'z',
},
];
expect(parseExpression('SORT(arr, MULTIPLY(a, -1))', { arr })).toEqual([arr[2], arr[0], arr[1]]);
expect(parseExpression('SORT(arr, b)', { arr })).toEqual([arr[1], arr[0], arr[2]]);
});
});

describe('JSON ops', () => {
test('JSON_PARSE op', () => {
expect(parseExpression('JSON_PARSE(a)', { a: '{"a": 1}' })).toStrictEqual({ a: 1 });
expect(parseExpression('JSON_PARSE("{\\"a\\": 1}")', {})).toStrictEqual({ a: 1 });
expect(parseExpression('JSON_PARSE("{\\"a\\": {\\"b\\": \\"c\\"}}")', {})).toStrictEqual({ a: { b: 'c' } });
expect(() => parseExpression('JSON_PARSE(a)', { a: '{"a": 1' })).toThrow(SyntaxError)
});

test('JSON_STRINGIFY op', () => {
expect(parseExpression('JSON_STRINGIFY(a)', { a: { a: 1 } })).toBe('{"a":1}');
});

test('JSON_GET op', () => {
expect(parseExpression('JSON_GET(a, "a")', { a: { a: 1 } })).toBe(1);
expect(parseExpression('JSON_GET(a, "b")', { a: { a: 1 } })).toBe(null);
expect(parseExpression('JSON_GET(AT(a, 0), "b")', { a: [{ b: 2 }] })).toBe(2);
expect(parseExpression('JSON_GET(a, "a")', { a: 1 })).toBe(null);
expect(parseExpression('JSON_GET(a, "a")', { a: null })).toBe(null);
});
});

describe('Relational ops', () => {
Expand Down Expand Up @@ -399,6 +602,15 @@ describe('Test parseExpression', () => {
});
});

describe('Other ops', () => {
test('RANGE op', () => {
expect(parseExpression('RANGE(a, b, c)', { a: 1, b: 5, c: 1 })).toEqual([1, 2, 3, 4, 5]);
expect(parseExpression('RANGE(a, b, c)', { a: 5, b: 1, c: -1 })).toEqual([5, 4, 3, 2 ,1]);
expect(parseExpression('RANGE(a, b, c)', { a: 1, b: 6, c: 2 })).toEqual([1, 3, 5]);
expect(parseExpression('RANGE(a, b, c)', { a: 5, b: 0, c: -2 })).toEqual([5, 3, 1]);
});
});

describe('Nested expressions', () => {
test('Simple nested numeric expression', () => {
expect(parseExpression('SUM(a, MULTIPLY(b, c))', { a: 1, b: 2, c: 3 })).toBe(7);
Expand Down
Loading

0 comments on commit 146cd29

Please sign in to comment.