From 0deb93eeae4f530901e9a7d099bcc47207ad7385 Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sun, 23 Feb 2025 14:57:17 +0000 Subject: [PATCH] feat: add find_index, has, and reject filters (#799) * feat: add find_index, has, and reject filters * Minor tweaks * Change semantics of jekyllStyle, add more tests * Some docs improvements --- docs/source/_data/sidebar.yml | 6 + docs/source/filters/find_index.md | 25 +++ docs/source/filters/find_index_exp.md | 25 +++ docs/source/filters/has.md | 25 +++ docs/source/filters/has_exp.md | 25 +++ docs/source/filters/reject.md | 118 +++++++++++ docs/source/filters/reject_exp.md | 37 ++++ docs/source/filters/where.md | 8 +- src/filters/array.ts | 88 +++++++-- test/integration/filters/array.spec.ts | 261 ++++++++++++++++++++++++- 10 files changed, 595 insertions(+), 23 deletions(-) create mode 100644 docs/source/filters/find_index.md create mode 100644 docs/source/filters/find_index_exp.md create mode 100644 docs/source/filters/has.md create mode 100644 docs/source/filters/has_exp.md create mode 100644 docs/source/filters/reject.md create mode 100644 docs/source/filters/reject_exp.md diff --git a/docs/source/_data/sidebar.yml b/docs/source/_data/sidebar.yml index 5ff7ebb6c..7f54e8baa 100644 --- a/docs/source/_data/sidebar.yml +++ b/docs/source/_data/sidebar.yml @@ -51,10 +51,14 @@ filters: escape_once: escape_once.html find: find.html find_exp: find_exp.html + find_index: find_index.html + find_index_exp: find_index_exp.html first: first.html floor: floor.html group_by: group_by.html group_by_exp: group_by_exp.html + has: has.html + has_exp: has_exp.html inspect: inspect.html join: join.html json: json.html @@ -72,6 +76,8 @@ filters: push: push.html prepend: prepend.html raw: raw.html + reject: reject.html + reject_exp: reject_exp.html remove: remove.html remove_first: remove_first.html remove_last: remove_last.html diff --git a/docs/source/filters/find_index.md b/docs/source/filters/find_index.md new file mode 100644 index 000000000..7819097b0 --- /dev/null +++ b/docs/source/filters/find_index.md @@ -0,0 +1,25 @@ +--- +title: find_index +--- + +{% since %}v10.21.0{% endsince %} + +Return the 0-based index of the first object in an array for which the queried attribute has the given value or return `nil` if no item in the array satisfies the given criteria. For the following `members` array: + +```javascript +const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack' } +] +``` + +Input +```liquid +{{ members | find_index: "graduation_year", 2014 | json }} +``` + +Output +```text +1 +``` diff --git a/docs/source/filters/find_index_exp.md b/docs/source/filters/find_index_exp.md new file mode 100644 index 000000000..1f9a40ded --- /dev/null +++ b/docs/source/filters/find_index_exp.md @@ -0,0 +1,25 @@ +--- +title: find_index_exp +--- + +{% since %}v10.21.0{% endsince %} + +Return the 0-based index of the first object in an array for which the given expression evaluates to true or return `nil` if no item in the array satisfies the evaluated expression. + +```javascript +const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack' } +] +``` + +Input +```liquid +{{ members | find_index_exp: "item", "item.graduation_year == 2014" | json }} +``` + +Output +```text +1 +``` diff --git a/docs/source/filters/has.md b/docs/source/filters/has.md new file mode 100644 index 000000000..27daf634f --- /dev/null +++ b/docs/source/filters/has.md @@ -0,0 +1,25 @@ +--- +title: has +--- + +{% since %}v10.21.0{% endsince %} + +Return `true` if the array includes an item for which the queried attribute has the given value or return `false` if no item in the array satisfies the given criteria. For the following `members` array: + +```javascript +const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack' } +] +``` + +Input +```liquid +{{ members | has: "graduation_year", 2014 | json }} +``` + +Output +```text +true +``` diff --git a/docs/source/filters/has_exp.md b/docs/source/filters/has_exp.md new file mode 100644 index 000000000..379f67231 --- /dev/null +++ b/docs/source/filters/has_exp.md @@ -0,0 +1,25 @@ +--- +title: has_exp +--- + +{% since %}v10.21.0{% endsince %} + +Return `true` if an item exists in an array for which the given expression evaluates to true or return `false` if no item in the array satisfies the evaluated expression. + +```javascript +const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack' } +] +``` + +Input +```liquid +{{ members | has_exp: "item", "item.graduation_year == 2014" | json }} +``` + +Output +```text +true +``` diff --git a/docs/source/filters/reject.md b/docs/source/filters/reject.md new file mode 100644 index 000000000..516d9047f --- /dev/null +++ b/docs/source/filters/reject.md @@ -0,0 +1,118 @@ +--- +title: reject +--- + +{% since %}v10.21.0{% endsince %} + +Creates an array excluding the objects with a given property value, or excluding [truthy][truthy] values by default when a property is not given. + +In this example, assume you have a list of products and you want to filter out kitchen products. Using `reject`, you can create an array excluding only the products that have a `"type"` of `"kitchen"`. + +Input +```liquid +All products: +{% for product in products %} +- {{ product.title }} +{% endfor %} + +{% assign non_kitchen_products = products | reject: "type", "kitchen" %} + +Kitchen products: +{% for product in non_kitchen_products %} +- {{ product.title }} +{% endfor %} +``` + +Output +```text +All products: +- Vacuum +- Spatula +- Television +- Garlic press + +Kitchen products: +- Vacuum +- Television +``` + +Say instead you have a list of products and you want to exclude taxable products. You can `reject` with a property name but no target value to reject all products with a [truthy][truthy] `"taxable"` value. + +Input +```liquid +All products: +{% for product in products %} +- {{ product.title }} +{% endfor %} + +{% assign not_taxed_products = products | reject: "taxable" %} + +Available products: +{% for product in not_taxed_products %} +- {{ product.title }} +{% endfor %} +``` + +Output +```text +All products: +- Vacuum +- Spatula +- Television +- Garlic press + +Available products: +- Spatula +- Television +``` + +Additionally, `property` can be any valid Liquid variable expression as used in output syntax, except that the scope of this expression is within each item. For the following `products` array: + +```javascript +const products = [ + { meta: { details: { class: 'A' } }, order: 1 }, + { meta: { details: { class: 'B' } }, order: 2 }, + { meta: { details: { class: 'B' } }, order: 3 } +] +``` + +Input +```liquid +{% assign selected = products | reject: 'meta.details["class"]', "B" %} +{% for item in selected -%} +- {{ item.order }} +{% endfor %} +``` + +Output +```text +- 1 +``` + +## Jekyll style + +{% since %}v10.21.0{% endsince %} + +For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for excluding tags. + +```javascript +const pages = [ + { tags: ["cat", "food"], title: 'Cat Food' }, + { tags: ["dog", "food"], title: 'Dog Food' }, +] +``` + +Input +```liquid +{% assign selected = pages | reject: 'tags', "cat" %} +{% for item in selected -%} +- {{ item.title }} +{% endfor %} +``` + +Output +```text +Dog Food +``` + +[truthy]: ../tutorials/truthy-and-falsy.html diff --git a/docs/source/filters/reject_exp.md b/docs/source/filters/reject_exp.md new file mode 100644 index 000000000..2f37d8555 --- /dev/null +++ b/docs/source/filters/reject_exp.md @@ -0,0 +1,37 @@ +--- +title: reject_exp +--- + +{% since %}v10.21.0{% endsince %} + +Select all the objects in an array where the expression is false. In this example, assume you have a list of products and you want to hide your kitchen products. Using `reject_exp`, you can create an array that omits only the products that have a `"type"` of `"kitchen"`. + +Input +```liquid +All products: +{% for product in products %} +- {{ product.title }} +{% endfor %} + +{% assign non_kitchen_products = products | reject_exp: "item", "item.type == 'kitchen'" %} + +Kitchen products: +{% for product in non_kitchen_products %} +- {{ product.title }} +{% endfor %} +``` + +Output +```text +All products: +- Vacuum +- Spatula +- Television +- Garlic press + +Kitchen products: +- Vacuum +- Television +``` + +[truthy]: ../tutorials/truthy-and-falsy.html diff --git a/docs/source/filters/where.md b/docs/source/filters/where.md index 2b98f54f0..d20c7346e 100644 --- a/docs/source/filters/where.md +++ b/docs/source/filters/where.md @@ -37,6 +37,7 @@ Kitchen products: ``` Say instead you have a list of products and you only want to show those that are available to buy. You can `where` with a property name but no target value to include all products with a [truthy][truthy] `"available"` value. +As a special case, the same will happen if the target value is given but evaluates to `undefined`. Input ```liquid @@ -70,7 +71,6 @@ The `where` filter can also be used to find a single object in an array when com Input ```liquid {% assign new_shirt = products | where: "type", "shirt" | first %} - Featured product: {{ new_shirt.title }} ``` @@ -105,9 +105,11 @@ Output ## Jekyll style -{% since %}v10.19.0{% endsince %} +{% since %}v10.21.0{% endsince %} -For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for filtering tags. +For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for filtering tags. Additionally, a target value of `undefined` is treated normally, entries matched are exactly those which are themselves `undefined`. + +This option affects other array selection filters as well, such as `reject` and `find`. ```javascript const pages = [ diff --git a/src/filters/array.ts b/src/filters/array.ts index cf967f284..6792d572c 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -117,7 +117,17 @@ export function slice (this: FilterImpl, v: T[] | string, begin: number, leng return v.slice(begin, begin + length) } -export function * where (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator { +function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean { + if (this.context.opts.jekyllWhere) { + return (v: any) => EmptyDrop.is(expected) ? equals(v, expected) : (isArray(v) ? arrayIncludes(v, expected) : equals(v, expected)) + } else if (expected === undefined) { + return (v: any) => isTruthy(v, this.context) + } else { + return (v: any) => equals(v, expected) + } +} + +function * filter (this: FilterImpl, include: boolean, arr: T[], property: string, expected: any): IterableIterator { const values: unknown[] = [] arr = toArray(arr) this.context.memoryLimit.use(arr.length) @@ -125,16 +135,11 @@ export function * where (this: FilterImpl, arr: T[], property: for (const item of arr) { values.push(yield evalToken(token, this.context.spawn(item))) } - const matcher = this.context.opts.jekyllWhere - ? (v: any) => EmptyDrop.is(expected) ? equals(v, expected) : (isArray(v) ? arrayIncludes(v, expected) : equals(v, expected)) - : (v: any) => equals(v, expected) - return arr.filter((_, i) => { - if (expected === undefined) return isTruthy(values[i], this.context) - return matcher(values[i]) - }) + const matcher = expectedMatcher.call(this, expected) + return arr.filter((_, i) => matcher(values[i]) === include) } -export function * where_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { +function * filter_exp (this: FilterImpl, include: boolean, arr: T[], itemName: string, exp: string): IterableIterator { const filtered: unknown[] = [] const keyTemplate = new Value(stringify(exp), this.liquid) const array = toArray(arr) @@ -143,11 +148,27 @@ export function * where_exp (this: FilterImpl, arr: T[], itemN this.context.push({ [itemName]: item }) const value = yield keyTemplate.value(this.context) this.context.pop() - if (value) filtered.push(item) + if (value === include) filtered.push(item) } return filtered } +export function * where (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator { + return yield * filter.call(this, true, arr, property, expected) +} + +export function * reject (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator { + return yield * filter.call(this, false, arr, property, expected) +} + +export function * where_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { + return yield * filter_exp.call(this, true, arr, itemName, exp) +} + +export function * reject_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { + return yield * filter_exp.call(this, false, arr, itemName, exp) +} + export function * group_by (this: FilterImpl, arr: T[], property: string): IterableIterator { const map = new Map() arr = toEnumerable(arr) @@ -176,26 +197,57 @@ export function * group_by_exp (this: FilterImpl, arr: T[], it return [...map.entries()].map(([name, items]) => ({ name, items })) } -export function * find (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator { +function * search (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator { const token = new Tokenizer(stringify(property)).readScopeValue() const array = toArray(arr) - for (const item of array) { - const value = yield evalToken(token, this.context.spawn(item)) - if (equals(value, expected)) return item + const matcher = expectedMatcher.call(this, expected) + for (let index = 0; index < array.length; index++) { + const value = yield evalToken(token, this.context.spawn(array[index])) + if (matcher(value)) return [index, array[index]] } } -export function * find_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { +function * search_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { const predicate = new Value(stringify(exp), this.liquid) const array = toArray(arr) - for (const item of array) { - this.context.push({ [itemName]: item }) + for (let index = 0; index < array.length; index++) { + this.context.push({ [itemName]: array[index] }) const value = yield predicate.value(this.context) this.context.pop() - if (value) return item + if (value) return [index, array[index]] } } +export function * has (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator { + const result = yield * search.call(this, arr, property, expected) + return !!result +} + +export function * has_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { + const result = yield * search_exp.call(this, arr, itemName, exp) + return !!result +} + +export function * find_index (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator { + const result = yield * search.call(this, arr, property, expected) + return result ? result[0] : undefined +} + +export function * find_index_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { + const result = yield * search_exp.call(this, arr, itemName, exp) + return result ? result[0] : undefined +} + +export function * find (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator { + const result = yield * search.call(this, arr, property, expected) + return result ? result[1] : undefined +} + +export function * find_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { + const result = yield * search_exp.call(this, arr, itemName, exp) + return result ? result[1] : undefined +} + export function uniq (this: FilterImpl, arr: T[]): T[] { arr = toArray(arr) this.context.memoryLimit.use(arr.length) diff --git a/test/integration/filters/array.spec.ts b/test/integration/filters/array.spec.ts index 5c7768053..eb28db78d 100644 --- a/test/integration/filters/array.spec.ts +++ b/test/integration/filters/array.spec.ts @@ -417,6 +417,32 @@ describe('filters/array', function () { - Boring sneakers `) }) + it('should support filter with undefined target', function () { + return test(`{% assign typed_products = products | where: "type", notdefined %} + Typed products: + {% for product in typed_products -%} + - {{ product.title }} + {% endfor %}`, { products }, ` + Typed products: + - Vacuum + - Spatula + - Television + - Garlic press + `) + }) + it('should support no target', function () { + return test(`{% assign typed_products = products | where: "type" %} + Typed products: + {% for product in typed_products -%} + - {{ product.title }} + {% endfor %}`, { products }, ` + Typed products: + - Vacuum + - Spatula + - Television + - Garlic press + `) + }) it('should support nested property', async function () { const authors = [ { name: 'Alice', books: { year: 2019 } }, @@ -491,7 +517,7 @@ describe('filters/array', function () { await test('{{objs | where: "foo", "FOO" | json}}', scope, '[]') }) describe('jekyll style', () => { - it('should not match string with array', async () => { + it('should filter arrays by inclusion', async () => { const scope = { objs: [{ foo: ['FOO', 'bar'] }] } await test('{{objs | where: "foo", "FOO" | json}}', scope, '[{"foo":["FOO","bar"]}]', { jekyllWhere: true }) }) @@ -499,6 +525,14 @@ describe('filters/array', function () { const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] } await test('{{pages | where: "tags", empty | json}}', scope, '[{"tags":[]}]', { jekyllWhere: true }) }) + it('should filter by undefined when target is omitted', async () => { + await test('{{products | where: "type" | map: "title" | join: ","}}', { products }, + 'Coffee mug,Limited edition sneakers,Boring sneakers', { jekyllWhere: true }) + }) + it('should filter plainly when target is undefined', async () => { + await test('{{products | where: "type", notdefined | map: "title" | join: ","}}', { products }, + 'Coffee mug,Limited edition sneakers,Boring sneakers', { jekyllWhere: true }) + }) }) }) describe('where_exp', function () { @@ -537,6 +571,89 @@ describe('filters/array', function () { return test(tpl, scope, html) }) }) + describe('reject', function () { + const products = [ + { title: 'Vacuum', type: 'living room' }, + { title: 'Spatula', type: 'kitchen' }, + { title: 'Television', type: 'living room' }, + { title: 'Garlic press', type: 'kitchen' }, + { title: 'Coffee mug', available: true }, + { title: 'Limited edition sneakers', available: false }, + { title: 'Boring sneakers', available: true } + ] + it('should support reject by property value', function () { + return test(`{% assign kitchen_products = products | reject: "type", "kitchen" %} + Kitchen products: + {% for product in kitchen_products -%} + - {{ product.title }} + {% endfor %}`, { products }, ` + Kitchen products: + - Vacuum + - Television + - Coffee mug + - Limited edition sneakers + - Boring sneakers + `) + }) + it('should support reject truthy property', function () { + return test(`{% assign unavailable_products = products | reject: "available" %} + Unavailable products: + {% for product in unavailable_products -%} + - {{ product.title }} + {% endfor %}`, { products }, ` + Unavailable products: + - Vacuum + - Spatula + - Television + - Garlic press + - Limited edition sneakers + `) + }) + it('should support reject by string property', function () { + return test(`{% assign untyped_products = products | reject: "type" %} + Untyped products: + {% for product in untyped_products -%} + - {{ product.title }} + {% endfor %}`, { products }, ` + Untyped products: + - Coffee mug + - Limited edition sneakers + - Boring sneakers + `) + }) + describe('jekyll style', () => { + it('should filter arrays by exclusion', async () => { + const scope = { objs: [{ foo: ['FOO', 'bar'] }, { foo: ['bar', 'baz'] }, { foo: ['FOO'] }] } + await test('{{objs | reject: "foo", "FOO" | json}}', scope, '[{"foo":["bar","baz"]}]', { jekyllWhere: true }) + }) + it('should filter by undefined when target is omitted', async () => { + await test('{{products | reject: "type" | map: "title" | join: ","}}', { products }, + 'Vacuum,Spatula,Television,Garlic press', { jekyllWhere: true }) + }) + }) + }) + describe('reject_exp', function () { + const products = [ + { title: 'Vacuum', type: 'living room' }, + { title: 'Spatula', type: 'kitchen' }, + { title: 'Television', type: 'living room' }, + { title: 'Garlic press', type: 'kitchen' }, + { title: 'Coffee mug', available: true }, + { title: 'Limited edition sneakers', available: false }, + { title: 'Boring sneakers', available: true } + ] + it('should support reject by exp', function () { + return test(`{% assign kitchen_products = products | reject_exp: "item", "item.type != 'kitchen'" %} + Kitchen products: + {% for product in kitchen_products -%} + - {{ product.title }} + {% endfor %}`, { products }, ` + Kitchen products: + - Spatula + - Garlic press + `) + }) + }) describe('group_by', function () { const members = [ { graduation_year: 2003, name: 'Jay' }, @@ -616,12 +733,76 @@ describe('filters/array', function () { JSON.stringify(expected)) }) }) - describe('find', function () { + describe('has', function () { + const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack', age: 13 } + ] + it('should support has with no value', function () { + return test( + `{{ members | has: "age" | json }}, {{ members | has: "height" | json }}`, + { members }, + `true, false`) + }) + it('should support has by property', function () { + return test( + `{{ members | has: "graduation_year", 2014 | json }}`, + { members }, + `true`) + }) + it('should return false if not found', function () { + return test( + `{{ members | has: "graduation_year", 2018 | json }}`, + { members }, + `false`) + }) + describe('jekyll style', () => { + it('should select array by inclusion', async () => { + const scope = { objs: [{ foo: ['FOO', 'bar'] }] } + await test('{{objs | has: "foo", "FOO" | json}}', scope, 'true', { jekyllWhere: true }) + }) + it('should support empty as target', async () => { + const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] } + await test('{{pages | has: "tags", empty | json}}', scope, 'true', { jekyllWhere: true }) + }) + it('should search plainly when target is undefined', async () => { + await test('{{members | has: "age", notdefined}}', { members }, 'true', { jekyllWhere: true }) + await test('{{members | has: "name", notdefined}}', { members }, 'false', { jekyllWhere: true }) + }) + }) + }) + describe('has_exp', function () { const members = [ { graduation_year: 2013, name: 'Jay' }, { graduation_year: 2014, name: 'John' }, { graduation_year: 2014, name: 'Jack' } ] + it('should support has by expression', function () { + return test( + `{{ members | has_exp: "item", "item.graduation_year == 2014" | json }}`, + { members }, + `true`) + }) + it('should return false if not found', function () { + return test( + `{{ members | has_exp: "item", "item.graduation_year == 2018" | json }}`, + { members }, + `false`) + }) + }) + describe('find', function () { + const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack', age: 13 } + ] + it('should support find with no value', function () { + return test( + `{{ members | find: "age" | json }}`, + { members }, + `{"graduation_year":2014,"name":"Jack","age":13}`) + }) it('should support find by property', function () { return test( `{{ members | find: "graduation_year", 2014 | json }}`, @@ -634,6 +815,24 @@ describe('filters/array', function () { { members }, ``) }) + describe('jekyll style', () => { + it('should select array by inclusion', async () => { + const scope = { objs: [{ foo: ['FOO', 'bar'] }] } + await test('{{objs | find: "foo", "FOO" | json}}', scope, '{"foo":["FOO","bar"]}', { jekyllWhere: true }) + }) + it('should support empty as target', async () => { + const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] } + await test('{{pages | find: "tags", empty | json}}', scope, '{"tags":[]}', { jekyllWhere: true }) + }) + it('should search plainly when target is undefined', async () => { + await test( + '{{members | find: "age", notdefined | json}}', + { members }, + '{"graduation_year":2013,"name":"Jay"}', + { jekyllWhere: true }) + await test('{{members | find: "name", notdefined | json}}', { members }, '', { jekyllWhere: true }) + }) + }) }) describe('find_exp', function () { const members = [ @@ -654,4 +853,62 @@ describe('filters/array', function () { ``) }) }) + describe('find_index', function () { + const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack', age: 13 } + ] + it('should support find_index with no value', function () { + return test( + `{{ members | find_index: "age" | json }}`, + { members }, + `2`) + }) + it('should support find_index by property', function () { + return test( + `{{ members | find_index: "graduation_year", 2014 | json }}`, + { members }, + `1`) + }) + it('should render none if not found', function () { + return test( + `{{ members | find_index: "graduation_year", 2018 | json }}`, + { members }, + ``) + }) + describe('jekyll style', () => { + it('should select array by inclusion', async () => { + const scope = { objs: [{ foo: ['FOO', 'bar'] }] } + await test('{{objs | find_index: "foo", "FOO" | json}}', scope, '0', { jekyllWhere: true }) + }) + it('should support empty as target', async () => { + const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] } + await test('{{pages | find_index: "tags", empty | json}}', scope, '1', { jekyllWhere: true }) + }) + it('should search plainly when target is undefined', async () => { + await test('{{members | find_index: "age", notdefined | json}}', { members }, '0', { jekyllWhere: true }) + await test('{{members | find_index: "name", notdefined | json}}', { members }, '', { jekyllWhere: true }) + }) + }) + }) + describe('find_index_exp', function () { + const members = [ + { graduation_year: 2013, name: 'Jay' }, + { graduation_year: 2014, name: 'John' }, + { graduation_year: 2014, name: 'Jack' } + ] + it('should support find_index by expression', function () { + return test( + `{{ members | find_index_exp: "item", "item.graduation_year == 2014" | json }}`, + { members }, + `1`) + }) + it('should render none if not found', function () { + return test( + `{{ members | find_index_exp: "item", "item.graduation_year == 2018" | json }}`, + { members }, + ``) + }) + }) })