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
74 changes: 74 additions & 0 deletions packages/@ember/helper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
gte as glimmerGte,
lt as glimmerLt,
lte as glimmerLte,
and as glimmerAnd,
or as glimmerOr,
not as glimmerNot,
} from '@glimmer/runtime';
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
import { type Opaque } from '@ember/-internals/utility-types';
Expand Down Expand Up @@ -665,4 +668,75 @@ export interface EqHelper extends Opaque<'helper:eq'> {}
export const neq = glimmerNeq as unknown as NeqHelper;
export interface NeqHelper extends Opaque<'helper:neq'> {}

/**
* The `{{and}}` helper evaluates arguments left to right, returning the first
* falsy value (using Handlebars truthiness) or the right-most value if all
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel liks since this will be going in the API docs we need to have some sort of cross-link to some document that explains what Handlebars truthiness actually means 😂 I can see in the implementation that we're using the toBool() function, does that have public API docs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have an idea for this! Hoping to PR some stuff this week

* are truthy. Requires at least two arguments.
*
* ```js
* import { and } from '@ember/helper';
*
* <template>
* {{if (and @isAdmin @isLoggedIn) "Welcome, admin!" "Access denied"}}
* </template>
* ```
*
* In strict-mode (gjs/gts) templates, `and` is available as a keyword and
* does not need to be imported.
*
* @method and
* @param {unknown} args Two or more values to evaluate
* @return {unknown} The first falsy value or the last value
* @private
*/
export const and = glimmerAnd as unknown as AndHelper;
export interface AndHelper extends Opaque<'helper:and'> {}

/**
* The `{{or}}` helper evaluates arguments left to right, returning the first
* truthy value (using Handlebars truthiness) or the right-most value if all
* are falsy. Requires at least two arguments.
*
* ```js
* import { or } from '@ember/helper';
*
* <template>
* {{if (or @hasAccess @isAdmin) "Welcome!" "No access"}}
* </template>
* ```
*
* In strict-mode (gjs/gts) templates, `or` is available as a keyword and
* does not need to be imported.
*
* @method or
* @param {unknown} args Two or more values to evaluate
* @return {unknown} The first truthy value or the last value
* @private
*/
export const or = glimmerOr as unknown as OrHelper;
export interface OrHelper extends Opaque<'helper:or'> {}

/**
* The `{{not}}` helper returns the logical negation of its argument using
* Handlebars truthiness. Takes exactly one argument.
*
* ```js
* import { not } from '@ember/helper';
*
* <template>
* {{if (not @isDisabled) "Enabled" "Disabled"}}
* </template>
* ```
*
* In strict-mode (gjs/gts) templates, `not` is available as a keyword and
* does not need to be imported.
*
* @method not
* @param {unknown} value The value to negate
* @return {boolean}
* @private
*/
export const not = glimmerNot as unknown as NotHelper;
export interface NotHelper extends Opaque<'helper:not'> {}

/* eslint-enable @typescript-eslint/no-empty-object-type */
5 changes: 4 additions & 1 deletion packages/@ember/template-compiler/lib/compile-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { array, element, eq, fn, hash, neq, lt, lte, gt, gte } from '@ember/helper';
import { and, array, element, eq, fn, hash, neq, not, lt, lte, gt, gte, or } from '@ember/helper';
import { on } from '@ember/modifier';
import { assert } from '@ember/debug';
import {
Expand Down Expand Up @@ -28,14 +28,17 @@ export const keywords: Record<string, unknown> = {
array,
eq,
element,
and,
fn,
hash,
neq,
gt,
gte,
lt,
lte,
not,
on,
or,
};

function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isLte(node, hasLocal)) {
rewriteKeyword(env, node, 'lte', '@ember/helper');
}
if (isAnd(node, hasLocal)) {
rewriteKeyword(env, node, 'and', '@ember/helper');
}
if (isOr(node, hasLocal)) {
rewriteKeyword(env, node, 'or', '@ember/helper');
}
if (isNot(node, hasLocal)) {
rewriteKeyword(env, node, 'not', '@ember/helper');
}
},
MustacheStatement(node: AST.MustacheStatement) {
if (isArray(node, hasLocal)) {
Expand Down Expand Up @@ -89,6 +98,15 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isLte(node, hasLocal)) {
rewriteKeyword(env, node, 'lte', '@ember/helper');
}
if (isAnd(node, hasLocal)) {
rewriteKeyword(env, node, 'and', '@ember/helper');
}
if (isOr(node, hasLocal)) {
rewriteKeyword(env, node, 'or', '@ember/helper');
}
if (isNot(node, hasLocal)) {
rewriteKeyword(env, node, 'not', '@ember/helper');
}
},
},
};
Expand Down Expand Up @@ -185,3 +203,24 @@ function isElement(
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'element' && !hasLocal('element');
}

function isAnd(
node: AST.MustacheStatement | AST.SubExpression,
hasLocal: (k: string) => boolean
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'and' && !hasLocal('and');
}

function isOr(
node: AST.MustacheStatement | AST.SubExpression,
hasLocal: (k: string) => boolean
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'or' && !hasLocal('or');
}

function isNot(
node: AST.MustacheStatement | AST.SubExpression,
hasLocal: (k: string) => boolean
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'not' && !hasLocal('not');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';

import { template } from '@ember/template-compiler/runtime';

class KeywordAndRuntime extends RenderTest {
static suiteName = 'keyword helper: and (runtime)';

@test
'explicit scope'() {
const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a: true, b: true }),
});

this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'explicit scope (shadowed)'() {
const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ and: () => false, a: true, b: true }),
});

this.renderComponent(compiled);
this.assertHTML('no');
}

@test
'implicit scope (eval)'() {
let a = true;
let b = 'hello';

hide(a);
hide(b);

const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
eval() {
return eval(arguments[0]);
},
});

this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'returns falsy when one arg is falsy'() {
const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a: true, b: 0 }),
});

this.renderComponent(compiled);
this.assertHTML('no');
}
}

jitSuite(KeywordAndRuntime);

const hide = (variable: unknown) => {
new Function(`return (${JSON.stringify(variable)});`);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { DEBUG } from '@glimmer/env';
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';

import { template } from '@ember/template-compiler';

class KeywordAnd extends RenderTest {
static suiteName = 'keyword helper: and';

@test
'references are lazy'(assert: Assert) {
const obj = {
get a() {
assert.step('a');
return 1;
},
get b() {
assert.step('b');
return 89;
},
get c() {
assert.step('c');
return false;
},
get d() {
assert.step('d');
return 'unexpected!!!';
},
};

const compiled = template('{{and obj.a obj.b obj.c obj.d}}', {
strictMode: true,
scope: () => ({ obj }),
});

this.renderComponent(compiled);
this.assertHTML('false');
assert.verifySteps(
['a', 'b', 'c'],
'd not evaluated because obj.c was the last to be evaluated and short-circuited'
);
}

@test
'explicit scope'() {
let a = 'yes';
let b = 'second';

const compiled = template('{{and a b}}', {
strictMode: true,
scope: () => ({ a, b }),
});

this.renderComponent(compiled);
this.assertHTML('second');
}

@test
'explicit scope (shadowed)'() {
let a = 'yes';
let b = true;
let and = () => 'surprise';
const compiled = template('{{and a b}}', {
strictMode: true,
scope: () => ({ and, a, b }),
});

this.renderComponent(compiled);
this.assertHTML('surprise');
}

@test
'implicit scope (eval)'() {
let a = true;
let b = 'hello';

hide(a);
hide(b);

const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
eval() {
return eval(arguments[0]);
},
});

this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'returns first falsy value'() {
let a = 0;
let b = 'hello';
const compiled = template('{{and a b}}', {
strictMode: true,
scope: () => ({ a, b }),
});

this.renderComponent(compiled);
this.assertHTML('0');
}

@test
'works as a SubExpression with if'() {
let a = true;
let b = true;
const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a, b }),
});

this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'treats empty array as falsy'() {
let a = true;
let b: unknown[] = [];
const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a, b }),
});

this.renderComponent(compiled);
this.assertHTML('no');
}

@test({ skip: !DEBUG })
'throws if called with less than two arguments'(assert: Assert) {
let a = true;
const compiled = template('{{and a}}', {
strictMode: true,
scope: () => ({ a }),
});

assert.throws(() => {
this.renderComponent(compiled);
}, /`and` expects at least two arguments/);
}
}

jitSuite(KeywordAnd);

const hide = (variable: unknown) => {
new Function(`return (${JSON.stringify(variable)});`);
};
Loading
Loading