Skip to content
Open
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 @@ -10,6 +10,9 @@ import {
concat as glimmerConcat,
get as glimmerGet,
fn as glimmerFn,
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 @@ -511,4 +514,75 @@ export interface ElementHelper extends Opaque<'helper:element'> {}
export const uniqueId = glimmerUniqueId;
export type UniqueIdHelper = typeof uniqueId;

/**
* The `{{and}}` helper evaluates arguments left to right, returning the first
* falsy value (using Handlebars truthiness) or the right-most value if all
* 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
* @public
*/
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
* @public
*/
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}
* @public
*/
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 { fn } from '@ember/helper';
import { and, fn, not, or } from '@ember/helper';
import { on } from '@ember/modifier';
import { assert } from '@ember/debug';
import {
Expand All @@ -25,8 +25,11 @@ function malformedComponentLookup(string: string) {
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';

export const keywords: Record<string, unknown> = {
and,
fn,
not,
on,
or,
};

function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,29 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isFn(node, hasLocal)) {
rewriteKeyword(env, node, 'fn', '@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 (isFn(node, hasLocal)) {
rewriteKeyword(env, node, 'fn', '@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 @@ -68,3 +86,24 @@ function isFn(
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn');
}

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,54 @@
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 without import'() {
const compiled = template('{{if (and a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a: true, b: true }),
});

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

@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,76 @@
import { DEBUG } from '@glimmer/env';
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';

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

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

@test
'returns right-most value when all are truthy'() {
let a = 1;
let b = 'hello';
const compiled = template('{{and a b}}', {
strictMode: true,
scope: () => ({ and, a, b }),
});

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

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

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

jitSuite(KeywordAnd);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';

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

class KeywordNotRuntime extends RenderTest {
static suiteName = 'keyword helper: not (runtime)';

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

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

@test
'implicit scope (eval)'() {
let a = false;

hide(a);

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

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

@test
'returns no for truthy'() {
const compiled = template('{{if (not a) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a: 'hello' }),
});

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

jitSuite(KeywordNotRuntime);

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