diff --git a/example/index.ts b/example/index.ts index 6b2cb75..366890d 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,4 +1,4 @@ -import edge from '..' +import edge from '../index' import { join } from 'path' import { createServer } from 'http' @@ -32,7 +32,6 @@ class User extends Base { const user = new User() user.parent = user -console.log(user) createServer((_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }) diff --git a/example/views/partials/button.edge b/example/views/partials/button.edge new file mode 100644 index 0000000..5fcb8d4 --- /dev/null +++ b/example/views/partials/button.edge @@ -0,0 +1 @@ + diff --git a/example/views/welcome.edge b/example/views/welcome.edge index 67255c5..7ef8df6 100644 --- a/example/views/welcome.edge +++ b/example/views/welcome.edge @@ -5,14 +5,16 @@ - {{ inspect(state.user) }} - @component('components.modal', title = 'Card', age = 22) + {{ inspect(state.user) }} + @set('time', 'afternoon') + + @component('components/modal', title = 'Card', age = 22) @slot('body')

hello

@endslot @slot('actions') - + @include('partials/button') @endslot @endcomponent diff --git a/fixtures/components-advanced-props/button.edge b/fixtures/components-advanced-props/button.edge new file mode 100644 index 0000000..ee08504 --- /dev/null +++ b/fixtures/components-advanced-props/button.edge @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/fixtures/components-advanced-props/compiled.js b/fixtures/components-advanced-props/compiled.js new file mode 100644 index 0000000..4927097 --- /dev/null +++ b/fixtures/components-advanced-props/compiled.js @@ -0,0 +1,9 @@ +let out = ""; +let $lineNumber = 1; +let $filename = "{{__dirname}}index.edge"; +try { +out += template.renderWithState("components-advanced-props/button", { class: 'mb-4 px-4', id: 'foo-bar', title: 'Click me' }, { main: function () { return "" } }, { filename: $filename, lineNumber: $lineNumber }); +} catch (error) { +ctx.reThrow(error, $filename, $lineNumber); +} +return out; \ No newline at end of file diff --git a/fixtures/components-advanced-props/index.edge b/fixtures/components-advanced-props/index.edge new file mode 100644 index 0000000..d806b71 --- /dev/null +++ b/fixtures/components-advanced-props/index.edge @@ -0,0 +1,5 @@ +@!component("components-advanced-props/button", { + class: 'mb-4 px-4', + id: 'foo-bar', + title: 'Click me' +}) \ No newline at end of file diff --git a/fixtures/components-advanced-props/index.json b/fixtures/components-advanced-props/index.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/fixtures/components-advanced-props/index.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/fixtures/components-advanced-props/index.txt b/fixtures/components-advanced-props/index.txt new file mode 100644 index 0000000..97fb58a --- /dev/null +++ b/fixtures/components-advanced-props/index.txt @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/fixtures/components-isolated-state/compiled.js b/fixtures/components-isolated-state/compiled.js index 165b23c..da64d8c 100644 --- a/fixtures/components-isolated-state/compiled.js +++ b/fixtures/components-isolated-state/compiled.js @@ -2,7 +2,7 @@ let out = ""; let $lineNumber = 1; let $filename = "{{__dirname}}index.edge"; try { -out += template.renderWithState("components-isolated-state/alert", {}, { main: function () { return "" } }); +out += template.renderWithState("components-isolated-state/alert", {}, { main: function () { return "" } }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/components-named-slots/compiled.js b/fixtures/components-named-slots/compiled.js index 74a045a..e3e45b6 100644 --- a/fixtures/components-named-slots/compiled.js +++ b/fixtures/components-named-slots/compiled.js @@ -26,7 +26,7 @@ slot_1 += " This is title"; ctx.reThrow(error, $filename, $lineNumber); } return slot_1; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/components-partials/compiled.js b/fixtures/components-partials/compiled.js index f1a7a05..d70d109 100644 --- a/fixtures/components-partials/compiled.js +++ b/fixtures/components-partials/compiled.js @@ -12,7 +12,7 @@ slot_main += `${ctx.escape(state.username || "Guest")}`; ctx.reThrow(error, $filename, $lineNumber); } return slot_main; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/components-props/compiled.js b/fixtures/components-props/compiled.js index d0cd54e..1abff46 100644 --- a/fixtures/components-props/compiled.js +++ b/fixtures/components-props/compiled.js @@ -10,7 +10,7 @@ slot_main += "Hello world"; ctx.reThrow(error, $filename, $lineNumber); } return slot_main; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/components-slot-props/compiled.js b/fixtures/components-slot-props/compiled.js index c713309..d00e652 100644 --- a/fixtures/components-slot-props/compiled.js +++ b/fixtures/components-slot-props/compiled.js @@ -16,7 +16,7 @@ slot_1 += `${ctx.escape(state.username)}`; ctx.reThrow(error, $filename, $lineNumber); } return slot_1; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/components-slots-partials/compiled.js b/fixtures/components-slots-partials/compiled.js index d1873a5..f0a5dd8 100644 --- a/fixtures/components-slots-partials/compiled.js +++ b/fixtures/components-slots-partials/compiled.js @@ -11,7 +11,7 @@ slot_main += template.renderInline("components-slots-partials/partial")(template ctx.reThrow(error, $filename, $lineNumber); } return slot_main; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/components-state/compiled.js b/fixtures/components-state/compiled.js index 8e0a972..b1cb201 100644 --- a/fixtures/components-state/compiled.js +++ b/fixtures/components-state/compiled.js @@ -12,7 +12,7 @@ slot_main += `${ctx.escape(state.username)}`; ctx.reThrow(error, $filename, $lineNumber); } return slot_main; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/components/compiled.js b/fixtures/components/compiled.js index 8690a9a..ddf0d1e 100644 --- a/fixtures/components/compiled.js +++ b/fixtures/components/compiled.js @@ -10,7 +10,7 @@ slot_main += " Hello world"; ctx.reThrow(error, $filename, $lineNumber); } return slot_main; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/fixtures/nested-components/compiled.js b/fixtures/nested-components/compiled.js index e5c4320..9397c0b 100644 --- a/fixtures/nested-components/compiled.js +++ b/fixtures/nested-components/compiled.js @@ -10,7 +10,7 @@ slot_main += " Hello world"; ctx.reThrow(error, $filename, $lineNumber); } return slot_main; -} }); +} }, { filename: $filename, lineNumber: $lineNumber }); } catch (error) { ctx.reThrow(error, $filename, $lineNumber); } diff --git a/npm-audit.html b/npm-audit.html index 49ef3f9..237da26 100644 --- a/npm-audit.html +++ b/npm-audit.html @@ -47,7 +47,7 @@
- 13 + 25

Dependencies

@@ -55,7 +55,7 @@
- November 15th 2020, 8:11:25 am + November 15th 2020, 12:16:39 pm

Last updated

diff --git a/package-lock.json b/package-lock.json index 96acdf0..1af0e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -339,7 +339,6 @@ "version": "2.5.7", "resolved": "https://registry.npmjs.org/@poppinss/utils/-/utils-2.5.7.tgz", "integrity": "sha512-O2Qjz+iIRTIJAwwH/bOml9nj4/kszcpoEyNoeir1KhxsyXhstnHNeTK9FsMlJz38JOs1y1h/tAP3qkkEHlTMkg==", - "dev": true, "requires": { "buffer-alloc": "^1.2.0", "fast-safe-stringify": "^2.0.7", @@ -1143,7 +1142,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, "requires": { "buffer-alloc-unsafe": "^1.1.0", "buffer-fill": "^1.0.0" @@ -1152,14 +1150,12 @@ "buffer-alloc-unsafe": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==" }, "buffer-fill": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", - "dev": true + "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=" }, "buffer-from": { "version": "1.1.1", @@ -2281,8 +2277,7 @@ "escape-goat": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", - "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", - "dev": true + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==" }, "escape-string-regexp": { "version": "1.0.5", @@ -2736,8 +2731,7 @@ "fast-safe-stringify": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", - "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==", - "dev": true + "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==" }, "fastq": { "version": "1.8.0", @@ -2931,8 +2925,7 @@ "fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", - "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", - "dev": true + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==" }, "fs.realpath": { "version": "1.0.0", @@ -4205,8 +4198,7 @@ "klona": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.4.tgz", - "integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==", - "dev": true + "integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==" }, "latest-version": { "version": "5.1.0", @@ -5851,8 +5843,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "mute-stream": { "version": "0.0.8", @@ -7304,8 +7295,7 @@ "require-all": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/require-all/-/require-all-3.0.0.tgz", - "integrity": "sha1-Rz1JcEvjEBFc4ST3c4Ox69hnExI=", - "dev": true + "integrity": "sha1-Rz1JcEvjEBFc4ST3c4Ox69hnExI=" }, "requireg": { "version": "0.2.2", @@ -7366,8 +7356,7 @@ "resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" }, "resolve-global": { "version": "1.0.0", @@ -7922,6 +7911,14 @@ "safe-buffer": "~5.1.0" } }, + "stringify-attributes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stringify-attributes/-/stringify-attributes-2.0.0.tgz", + "integrity": "sha512-wrVfRV6sCCB6wr3gx8OgKsp/9dSWWbKr8ifLfOxEcd/BBoa8d5pAf4BZb/jQW1JZnoZImjvUdxdo3ikYHZmYiw==", + "requires": { + "escape-goat": "^2.0.0" + } + }, "strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", diff --git a/package.json b/package.json index b0fa2d4..16004d2 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ }, "dependencies": { "@poppinss/inspect": "^1.0.1", + "@poppinss/utils": "^2.5.7", "edge-error": "^1.0.5", "edge-lexer": "^3.2.0", "edge-parser": "^5.3.0", @@ -80,6 +81,7 @@ "lodash.merge": "^4.6.2", "lodash.size": "^4.2.0", "macroable": "^5.0.3", + "stringify-attributes": "^2.0.0", "truncatise": "0.0.8" }, "husky": { diff --git a/src/Component/Props.ts b/src/Component/Props.ts new file mode 100644 index 0000000..5c5e0bc --- /dev/null +++ b/src/Component/Props.ts @@ -0,0 +1,96 @@ +/* + * edge + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { EdgeError } from 'edge-error' +import { lodash } from '@poppinss/utils' +import stringifyAttributes from 'stringify-attributes' +import { safeValue } from '../Context' + +/** + * Class to ease interactions with component props + */ +export class Props { + constructor(options: { + component: string + state: any + caller: { filename: string; lineNumber: number } + }) { + this[Symbol.for('options')] = options + Object.assign(this, options.state) + } + + /** + * Find if a key exists inside the props + */ + public has(key: string) { + const value = lodash.get(this[Symbol.for('options')].state, key) + return value !== undefined && value !== null + } + + /** + * Returns the value of a key from the props. An exception is raised + * if value is undefined or null. + */ + public get(key: string, defaultValue?: any) { + const value = lodash.get(this[Symbol.for('options')].state, key, defaultValue) + if (value === undefined || value === null) { + throw new EdgeError( + `"${key}" prop is required in order to render the "${ + this[Symbol.for('options')].component + }" component`, + 'E_MISSING_PROP', + { + filename: this[Symbol.for('options')].caller.filename, + line: this[Symbol.for('options')].caller.lineNumber, + col: 0, + } + ) + } + + return value + } + + /** + * Return only given keys + */ + public only(keys: string[]) { + return lodash.pick(this[Symbol.for('options')].state, keys) + } + + /** + * Return except the mentioned keys + */ + public except(keys: string[]) { + return lodash.omit(this[Symbol.for('options')].state, keys) + } + + /** + * Serializes props to attributes + */ + public serialize(mergeProps?: any) { + const props = this[Symbol.for('options')].state + return safeValue(stringifyAttributes(lodash.merge({}, props, mergeProps))) + } + + /** + * Serializes only the given props + */ + public serializeOnly(keys: string[], mergeProps?: any) { + const props = this.only(keys) + return safeValue(stringifyAttributes(lodash.merge({}, props, mergeProps))) + } + + /** + * Serialize all props except the given keys + */ + public serializeExcept(keys: string[], mergeProps?: any) { + const props = this.except(keys) + return safeValue(stringifyAttributes(lodash.merge({}, props, mergeProps))) + } +} diff --git a/src/Component/Slots.ts b/src/Component/Slots.ts new file mode 100644 index 0000000..2e4341c --- /dev/null +++ b/src/Component/Slots.ts @@ -0,0 +1,68 @@ +/* + * edge + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { EdgeError } from 'edge-error' +import { safeValue } from '../Context' +import { lodash } from '@poppinss/utils' + +/** + * Class to ease interactions with component slots + */ +export class Slots { + constructor(options: { + component: string + slots: { [name: string]: (...args: any[]) => string } + caller: { filename: string; lineNumber: number } + }) { + this[Symbol.for('options')] = options + Object.assign(this, options.slots) + } + + /** + * Find if a key slot exists or not + */ + public has(key: string) { + const value = lodash.get(this[Symbol.for('options')].slots, key) + return value !== undefined && value !== null + } + + /** + * Gives the return value of the slot by calling its function + */ + public render(name: string, ...args: any[]) { + const slotFn = lodash.get(this[Symbol.for('options')].slots, name) + + if (typeof slotFn !== 'function') { + throw new EdgeError( + `"${name}" slot is required in order to render the "${ + this[Symbol.for('options')].component + }" component`, + 'E_MISSING_SLOT', + { + filename: this[Symbol.for('options')].caller.filename, + line: this[Symbol.for('options')].caller.lineNumber, + col: 0, + } + ) + } + + return safeValue(slotFn(...args)) + } + + /** + * Render the slot if it exists + */ + public renderIfExists(name: string, ...args: any[]) { + const slotFn = lodash.get(this[Symbol.for('options')].slots, name) + if (typeof slotFn !== 'function') { + return '' + } + return safeValue(slotFn(...args)) + } +} diff --git a/src/Contracts/index.ts b/src/Contracts/index.ts index 2b8bdcc..ba24d91 100644 --- a/src/Contracts/index.ts +++ b/src/Contracts/index.ts @@ -112,7 +112,7 @@ export interface CompilerContract { */ export interface TemplateContract { renderInline(templatePath: string, ...localVariables: string[]): Function - renderWithState(template: string, state: any, slots: any): string + renderWithState(template: string, state: any, slots: any, caller: any): string render(template: string, state: any): string } diff --git a/src/Tags/Component.ts b/src/Tags/Component.ts index b8691f6..558f32b 100644 --- a/src/Tags/Component.ts +++ b/src/Tags/Component.ts @@ -312,11 +312,15 @@ export const componentTag: TagContract = { } }) + const caller = new StringifiedObject() + caller.add('filename', '$filename') + caller.add('lineNumber', '$lineNumber') + /** * Write the line to render the component with it's own state */ buffer.outputExpression( - `template.renderWithState(${name}, ${props}, ${obj.flush()})`, + `template.renderWithState(${name}, ${props}, ${obj.flush()}, ${caller.flush()})`, token.filename, token.loc.start.line, false diff --git a/src/Template/index.ts b/src/Template/index.ts index e010726..af689d4 100644 --- a/src/Template/index.ts +++ b/src/Template/index.ts @@ -10,6 +10,8 @@ import merge from 'lodash.merge' import { Context } from '../Context' import { Processor } from '../Processor' +import { Props } from '../Component/Props' +import { Slots } from '../Component/Slots' import { CompilerContract, TemplateContract } from '../Contracts' /** @@ -74,10 +76,14 @@ export class Template implements TemplateContract { * template.renderWithState('components.user', { username: 'virk' }, slotsIfAny) * ``` */ - public renderWithState(template: string, state: any, slots: any): string { + public renderWithState(template: string, state: any, slots: any, caller: any): string { const { template: compiledTemplate } = this.compiler.compile(template) - const templateState = Object.assign({}, this.sharedState, state, { $slots: slots }) + const templateState = Object.assign({}, this.sharedState, state, { + $slots: new Slots({ component: template, caller, slots }), + $caller: caller, + $props: new Props({ component: template, caller, state }), + }) const context = new Context() return this.wrapToFunction(compiledTemplate)(this, templateState, context) diff --git a/test/component.spec.ts b/test/component.spec.ts index 0ff16b9..05c1620 100644 --- a/test/component.spec.ts +++ b/test/component.spec.ts @@ -344,4 +344,32 @@ test.group('Component | render | errors', (group) => { assert.equal(error.filename, join(fs.basePath, 'button.edge')) } }) + + test('point error back to the caller when props validation fails', async (assert) => { + assert.plan(4) + + await fs.add('button.edge', `{{ $props.get('text') }}`) + + await fs.add( + 'eval.edge', + dedent` +

Some content

+ + @!component('button') + ` + ) + + const template = new Template(compiler, {}, {}, processor) + try { + template.render('eval.edge', {}) + } catch (error) { + assert.equal( + error.message, + '"text" prop is required in order to render the "button" component' + ) + assert.equal(error.line, 3) + assert.equal(error.col, 0) + assert.equal(error.filename, join(fs.basePath, 'eval.edge')) + } + }) }) diff --git a/test/props.spec.ts b/test/props.spec.ts new file mode 100644 index 0000000..fc1d193 --- /dev/null +++ b/test/props.spec.ts @@ -0,0 +1,243 @@ +/* + * edge + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import test from 'japa' +import { Props } from '../src/Component/Props' + +test.group('Props', () => { + test('get props value', (assert) => { + const props = new Props({ + component: 'foo', + state: { title: 'Hello' }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props.get('title'), 'Hello') + }) + + test('find if props has value', (assert) => { + const props = new Props({ + component: 'foo', + state: { title: 'Hello' }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.isTrue(props.has('title')) + }) + + test('raise error when prop value is missing', (assert) => { + assert.plan(3) + + const props = new Props({ + component: 'foo', + state: { title: 'Hello' }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + try { + props.get('age') + } catch (error) { + assert.equal(error.message, '"age" prop is required in order to render the "foo" component') + assert.equal(error.filename, 'bar.edge') + assert.equal(error.line, 1) + } + }) + + test('cherry pick values from the props', (assert) => { + const props = new Props({ + component: 'foo', + state: { title: 'Hello', label: 'Hello world', actionText: 'Confirm' }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.deepEqual(props.only(['label', 'actionText']), { + label: 'Hello world', + actionText: 'Confirm', + }) + }) + + test('get values except for the defined keys from the props', (assert) => { + const props = new Props({ + component: 'foo', + state: { title: 'Hello', label: 'Hello world', actionText: 'Confirm' }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.deepEqual(props.except(['label', 'actionText']), { + title: 'Hello', + }) + }) + + test('serialize props to html attributes', (assert) => { + const props = new Props({ + component: 'foo', + state: { + class: ['foo', 'bar'], + onclick: 'foo = bar', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props.serialize().value, ' class="foo bar" onclick="foo = bar"') + }) + + test('serialize by merging custom properties', (assert) => { + const props = new Props({ + component: 'foo', + state: { + class: ['foo', 'bar'], + onclick: 'foo = bar', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props.serialize({ id: '1' }).value, ' class="foo bar" onclick="foo = bar" id="1"') + }) + + test('serialize specific keys to html attributes', (assert) => { + const props = new Props({ + component: 'foo', + state: { + class: ['foo', 'bar'], + onclick: 'foo = bar', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props.serializeOnly(['class']).value, ' class="foo bar"') + }) + + test('serialize specific keys to merge custom properties', (assert) => { + const props = new Props({ + component: 'foo', + state: { + class: ['foo', 'bar'], + onclick: 'foo = bar', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props.serializeOnly(['class'], { id: '1' }).value, ' class="foo bar" id="1"') + }) + + test('serialize all except defined keys to html attributes', (assert) => { + const props = new Props({ + component: 'foo', + state: { + class: ['foo', 'bar'], + onclick: 'foo = bar', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props.serializeExcept(['class']).value, ' onclick="foo = bar"') + }) + + test('serialize specific keys to merge custom properties', (assert) => { + const props = new Props({ + component: 'foo', + state: { + class: ['foo', 'bar'], + onclick: 'foo = bar', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props.serializeExcept(['class'], { id: '1' }).value, ' onclick="foo = bar" id="1"') + }) + + test('copy state properties to the props class', (assert) => { + const props = new Props({ + component: 'foo', + state: { + class: ['foo', 'bar'], + onclick: 'foo = bar', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.deepEqual(props['class'], ['foo', 'bar']) + assert.deepEqual(props['onclick'], 'foo = bar') + }) + + test('access nested state properties from the props instance', (assert) => { + const props = new Props({ + component: 'foo', + state: { + user: { + name: 'virk', + }, + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(props['user']['name'], 'virk') + }) + + test('do not raise error when state is undefined', () => { + new Props({ + component: 'foo', + state: undefined, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + }) + + test('do not raise error when state is null', () => { + new Props({ + component: 'foo', + state: null, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + }) +}) diff --git a/test/slots.spec.ts b/test/slots.spec.ts new file mode 100644 index 0000000..ba59044 --- /dev/null +++ b/test/slots.spec.ts @@ -0,0 +1,75 @@ +/* + * edge + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import test from 'japa' +import { Slots } from '../src/Component/Slots' + +test.group('Slots', () => { + test('render slot', (assert) => { + const slots = new Slots({ + component: 'foo', + slots: { + main: () => 'hello', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(slots.render('main').value, 'hello') + }) + + test('raise error when slot is missing', (assert) => { + const slots = new Slots({ + component: 'foo', + slots: {}, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + try { + slots.render('main') + } catch (error) { + assert.equal(error.message, '"main" slot is required in order to render the "foo" component') + assert.equal(error.filename, 'bar.edge') + assert.equal(error.line, 1) + } + }) + + test('return empty string when slot is missing', (assert) => { + const slots = new Slots({ + component: 'foo', + slots: {}, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(slots.renderIfExists('main'), '') + }) + + test('access slots directly', (assert) => { + const slots = new Slots({ + component: 'foo', + slots: { + main: () => 'hello', + }, + caller: { + filename: 'bar.edge', + lineNumber: 1, + }, + }) + + assert.equal(slots['main'](), 'hello') + }) +})