Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check invalid elements in <Blocks> and <Input> strictly #64

Merged
merged 6 commits into from
Oct 1, 2019
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
### Changed

- Bump dependent packages to the latest version ([#59](https://github.com/speee/jsx-slack/pull/59))
- Check invalid elements in `<Blocks>` and `<Input>` strictly ([#64](https://github.com/speee/jsx-slack/pull/64))

### Deprecated

Expand Down
55 changes: 36 additions & 19 deletions src/block-kit/Blocks.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @jsx JSXSlack.h */
import { JSXSlack } from '../jsx'
import { JSXSlack, jsxOnParsed } from '../jsx'
import { ArrayOutput, isNode, wrap } from '../utils'
import { Divider, Image, Section } from './index'

Expand All @@ -18,35 +18,52 @@ export enum InternalBlockType {

export const blockTypeSymbol = Symbol('jsx-slack-block-type')

const knownBlocks = [
'actions',
'context',
'divider',
'file',
'image',
'input',
'section',
]

export const Blocks: JSXSlack.FC<BlocksProps> = props => {
const internalType: InternalBlockType | undefined = props[blockTypeSymbol]

const normalized = wrap(props.children).map(child => {
if (child && isNode(child)) {
if (typeof child.type === 'string') {
// Aliasing intrinsic elements to Block component
switch (child.type) {
case 'hr':
return <Divider {...child.props}>{...child.children}</Divider>
case 'img':
return <Image {...child.props}>{...child.children}</Image>
case 'section':
return <Section {...child.props}>{...child.children}</Section>
default:
throw new Error('<Blocks> allows only including Block component.')
}
} else if (child.type === JSXSlack.NodeType.object) {
// Check layout blocks
if (internalType === undefined && child.props.type === 'input')
if (child && isNode(child) && typeof child.type === 'string') {
// Aliasing intrinsic elements to Block component
switch (child.type) {
case 'hr':
return <Divider {...child.props}>{...child.children}</Divider>
case 'img':
return <Image {...child.props}>{...child.children}</Image>
case 'section':
return <Section {...child.props}>{...child.children}</Section>
default:
throw new Error(
'<Input> block cannot place in <Blocks> container for messaging.'
'<Blocks> allows only including layout block components.'
)
}
}
return child
})

return <ArrayOutput>{normalized}</ArrayOutput>
const node = <ArrayOutput>{normalized}</ArrayOutput>

node.props[jsxOnParsed] = parsed => {
// Check the final output again
if (parsed.some(b => !knownBlocks.includes(b.type)))
throw new Error('<Blocks> allows only including layout block components.')

if (internalType === undefined && parsed.some(b => b.type === 'input'))
throw new Error(
'<Input> block cannot place in <Blocks> container for messaging.'
)
}

return node
}

export const BlocksInternal: JSXSlack.FC<
Expand Down
28 changes: 25 additions & 3 deletions src/block-kit/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @jsx JSXSlack.h */
import { InputBlock } from '@slack/types'
import { JSXSlack } from '../jsx'
import { JSXSlack, jsxOnParsed } from '../jsx'
import { ObjectOutput, coerceToInteger } from '../utils'
import { BlockComponentProps } from './Blocks'
import { plainText } from './composition/utils'
Expand Down Expand Up @@ -43,6 +43,21 @@ export type WithInputProps<T> =
| T & { [key in keyof InputCommonProps]?: undefined }
| T & InputCommonProps

const knownInputs = [
'channels_select',
'conversations_select',
'datepicker',
'external_select',
'multi_channels_select',
'multi_conversations_select',
'multi_external_select',
'multi_static_select',
'multi_users_select',
'plain_text_input',
'static_select',
'users_select',
]

export const wrapInInput = (
element: JSXSlack.Node<any>,
props: InputCommonProps
Expand Down Expand Up @@ -77,8 +92,7 @@ export const Input: JSXSlack.FC<InputProps> = props => {
if (props.children === undefined) return InputComponent(props)

const hintText = props.hint || props.title

return (
const node = (
<ObjectOutput<InputBlock>
type="input"
block_id={props.id || props.blockId}
Expand All @@ -88,6 +102,14 @@ export const Input: JSXSlack.FC<InputProps> = props => {
element={JSXSlack(props.children)}
/>
)

node.props[jsxOnParsed] = parsed => {
// Check the final output
if (!(parsed.element && knownInputs.includes(parsed.element.type)))
throw new Error('A wrapped element in <Input> is invalid.')
}

return node
}

export const Textarea: JSXSlack.FC<TextareaProps> = props =>
Expand Down
82 changes: 47 additions & 35 deletions src/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { wrap } from './utils'

let internalExactMode = false

type OnParsed = (parsed: any, context: ParseContext) => void

enum ParseMode {
normal,
plainText,
Expand All @@ -18,13 +20,16 @@ export interface ParseContext {
mode: ParseMode
}

export function JSXSlack(
node: JSXSlack.Node,
context: ParseContext = {
Copy link
Owner Author

Choose a reason for hiding this comment

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

An exported JSXSlack no longer have second argument for private use. We updated to use this in internal parseJSX function.

builts: [],
elements: [],
mode: ParseMode.normal,
}
export const jsxOnParsed = Symbol('jsx-slack-jsxOnParsed')

export function JSXSlack(node: JSXSlack.Node) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return parseJSX(node, { builts: [], elements: [], mode: ParseMode.normal })
}

function parseJSX(
node: JSXSlack.Node & { [jsxOnParsed]?: OnParsed },
context: ParseContext
) {
const children = JSXSlack.normalizeChildren(
node.props.children || node.children || []
Expand All @@ -38,41 +43,48 @@ export function JSXSlack(
(arr, c) => {
const ctx = { ...nextCtx, builts: arr }
const ret =
typeof c === 'string' ? processString(c, ctx) : JSXSlack(c, ctx)
typeof c === 'string' ? processString(c, ctx) : parseJSX(c, ctx)

return [...ctx.builts, ...(ret ? [ret] : [])]
},
[] as any[]
)

switch (node.type) {
case JSXSlack.NodeType.object: {
const obj = { ...node.props }

Object.keys(obj).forEach(key => obj[key] === undefined && delete obj[key])
return obj
}
case JSXSlack.NodeType.array:
return toArray()
case JSXSlack.NodeType.html:
return turndown(toArray({ ...context, mode: ParseMode.HTML }).join(''))
case JSXSlack.NodeType.escapeInHtml:
return escapeChars(toArray().join(''))
case JSXSlack.NodeType.string:
return toArray({ ...context, mode: ParseMode.plainText }).join('')
default:
if (typeof node.type === 'string') {
context.elements.push(node.type)

try {
if (context.mode === ParseMode.plainText) return toArray()
return parse(node.type, node.props, toArray(), context)
} finally {
context.elements.pop()
const ret = (() => {
switch (node.type) {
case JSXSlack.NodeType.object:
return Object.keys(node.props).reduce(
(obj, k) =>
node.props[k] !== undefined ? { ...obj, [k]: node.props[k] } : obj,
{}
)
case JSXSlack.NodeType.array:
return toArray()
case JSXSlack.NodeType.html:
return turndown(toArray({ ...context, mode: ParseMode.HTML }).join(''))
case JSXSlack.NodeType.escapeInHtml:
return escapeChars(toArray().join(''))
case JSXSlack.NodeType.string:
return toArray({ ...context, mode: ParseMode.plainText }).join('')
default:
if (typeof node.type === 'string') {
context.elements.push(node.type)

try {
if (context.mode === ParseMode.plainText) return toArray()
return parse(node.type, node.props, toArray(), context)
} finally {
context.elements.pop()
}
}
}
throw new Error(`Unknown node type: ${node.type}`)
}
throw new Error(`Unknown node type: ${node.type}`)
}
})()

if (node.props[jsxOnParsed] !== undefined)
node.props[jsxOnParsed](ret, context)

return ret
}

// eslint-disable-next-line no-redeclare
Expand Down
43 changes: 43 additions & 0 deletions test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,30 @@ import JSXSlack, {
Textarea,
UsersSelect,
} from '../src/index'
import { jsxOnParsed } from '../src/jsx'

beforeEach(() => JSXSlack.exactMode(false))

describe('jsx-slack', () => {
describe('#JSXSlack', () => {
it('executes specified func after parsing if passed internal node has jsxOnParsed symbol prop', () => {
const onParsed = jest.fn()
const node = (
<SelectFragment>
<Option value="a">A</Option>
</SelectFragment>
)

node.props[jsxOnParsed] = onParsed
JSXSlack(node)

expect(onParsed).toBeCalledTimes(1)
})

it('throws error by passed invalid node', () =>
expect(() => JSXSlack({ props: {}, type: -1 } as any)).toThrow())
})

describe('Container components', () => {
describe('<Blocks>', () => {
it('throws error when <Blocks> has unexpected element', () => {
Expand All @@ -53,6 +73,14 @@ describe('jsx-slack', () => {
)
).toThrow()

expect(() =>
JSXSlack(
<Blocks>
<Escape>unexpected</Escape>
</Blocks>
)
).toThrow()

// <Input> block cannot use in message
expect(() =>
JSXSlack(
Expand Down Expand Up @@ -1056,6 +1084,21 @@ describe('jsx-slack', () => {
).blocks
).toStrictEqual(blocks)
})

it('throws error when wrapped invalid element', () => {
expect(() =>
JSXSlack(
<Modal title="test">
<Input label="invalid">
<Overflow actionId="overflow">
<OverflowItem value="a">A</OverflowItem>
<OverflowItem value="b">B</OverflowItem>
</Overflow>
</Input>
</Modal>
)
).toThrow(/invalid/)
})
})

describe('as block element for plain-text input', () => {
Expand Down