[IMPROVE] migrate all markdown container files

This commit is contained in:
AlexAlexandre 2021-07-21 12:15:13 -03:00
parent 1c7ae63ac1
commit a557d7f5d2
12 changed files with 147 additions and 179 deletions

View File

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { Text } from 'react-native'; import { Text } from 'react-native';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
@ -7,9 +6,19 @@ import { themes } from '../../constants/colors';
import styles from './styles'; import styles from './styles';
import { logEvent, events } from '../../utils/log'; import { logEvent, events } from '../../utils/log';
interface IAtMention {
mention: string;
username: string;
navToRoomInfo: Function;
style: any;
useRealName: boolean;
theme: string;
mentions: any;
}
const AtMention = React.memo(({ const AtMention = React.memo(({
mention, mentions, username, navToRoomInfo, style = [], useRealName, theme mention, mentions, username, navToRoomInfo, style = [], useRealName, theme
}) => { }: IAtMention) => {
if (mention === 'all' || mention === 'here') { if (mention === 'all' || mention === 'here') {
return ( return (
<Text <Text
@ -36,7 +45,7 @@ const AtMention = React.memo(({
}; };
} }
const user = mentions?.find?.(m => m && m.username === mention); const user = mentions?.find?.((m: any) => m && m.username === mention);
const handlePress = () => { const handlePress = () => {
logEvent(events.ROOM_MENTION_GO_USER_INFO); logEvent(events.ROOM_MENTION_GO_USER_INFO);
@ -65,14 +74,4 @@ const AtMention = React.memo(({
); );
}); });
AtMention.propTypes = {
mention: PropTypes.string,
username: PropTypes.string,
navToRoomInfo: PropTypes.func,
style: PropTypes.array,
useRealName: PropTypes.bool,
theme: PropTypes.string,
mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
};
export default AtMention; export default AtMention;

View File

@ -1,12 +1,16 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { View } from 'react-native'; import { View } from 'react-native';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
import styles from './styles'; import styles from './styles';
const BlockQuote = React.memo(({ children, theme }) => ( interface IBlockQuote {
children: JSX.Element;
theme: string;
}
const BlockQuote = React.memo(({ children, theme }: IBlockQuote) => (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.quote, { backgroundColor: themes[theme].borderColor }]} /> <View style={[styles.quote, { backgroundColor: themes[theme].borderColor }]} />
<View style={styles.childContainer}> <View style={styles.childContainer}>
@ -15,9 +19,4 @@ const BlockQuote = React.memo(({ children, theme }) => (
</View> </View>
)); ));
BlockQuote.propTypes = {
children: PropTypes.node.isRequired,
theme: PropTypes.string
};
export default BlockQuote; export default BlockQuote;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { Text } from 'react-native'; import { Text } from 'react-native';
@ -6,9 +5,20 @@ import { themes } from '../../constants/colors';
import styles from './styles'; import styles from './styles';
const Hashtag = React.memo(({ export type TChannel = {
hashtag, channels, navToRoomInfo, style = [], theme name: string;
}) => { _id: number;
}
interface IHashtag {
hashtag: string;
navToRoomInfo: Function;
style: [];
theme: string;
channels: TChannel[];
}
const Hashtag = React.memo(({ hashtag, channels, navToRoomInfo, style = [], theme }: IHashtag) => {
const handlePress = () => { const handlePress = () => {
const index = channels.findIndex(channel => channel.name === hashtag); const index = channels.findIndex(channel => channel.name === hashtag);
const navParam = { const navParam = {
@ -40,12 +50,4 @@ const Hashtag = React.memo(({
); );
}); });
Hashtag.propTypes = {
hashtag: PropTypes.string,
navToRoomInfo: PropTypes.func,
style: PropTypes.array,
theme: PropTypes.string,
channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
};
export default Hashtag; export default Hashtag;

View File

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { Text, Clipboard } from 'react-native'; import { Text, Clipboard } from 'react-native';
import styles from './styles'; import styles from './styles';
@ -9,9 +8,14 @@ import EventEmitter from '../../utils/events';
import I18n from '../../i18n'; import I18n from '../../i18n';
import openLink from '../../utils/openLink'; import openLink from '../../utils/openLink';
const Link = React.memo(({ interface ILink {
children, link, theme, onLinkPress children: JSX.Element;
}) => { link: string;
theme: string;
onLinkPress: Function;
}
const Link = React.memo(({ children, link, theme, onLinkPress }: ILink) => {
const handlePress = () => { const handlePress = () => {
if (!link) { if (!link) {
return; return;
@ -40,11 +44,4 @@ const Link = React.memo(({
); );
}); });
Link.propTypes = {
children: PropTypes.node,
link: PropTypes.string,
theme: PropTypes.string,
onLinkPress: PropTypes.func
};
export default Link; export default Link;

View File

@ -1,12 +1,18 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
const List = React.memo(({ interface IList {
children, ordered, start, tight, numberOfLines = 0 children: JSX.Element;
}) => { ordered: boolean;
start: number;
tight: boolean;
numberOfLines: number;
}
const List = React.memo(({ children, ordered, start = 1, tight, numberOfLines = 0 }: IList) => {
let bulletWidth = 15; let bulletWidth = 15;
if (ordered) { if (ordered) {
// @ts-ignore
const lastNumber = (start + children.length) - 1; const lastNumber = (start + children.length) - 1;
bulletWidth = (9 * lastNumber.toString().length) + 7; bulletWidth = (9 * lastNumber.toString().length) + 7;
} }
@ -17,7 +23,7 @@ const List = React.memo(({
items = items.slice(0, numberOfLines); items = items.slice(0, numberOfLines);
} }
const _children = items.map((child, index) => React.cloneElement(child, { const _children = items.map((child: any, index: number) => React.cloneElement(child, {
bulletWidth, bulletWidth,
ordered, ordered,
tight, tight,
@ -31,16 +37,4 @@ const List = React.memo(({
); );
}); });
List.propTypes = {
children: PropTypes.node,
ordered: PropTypes.bool,
start: PropTypes.number,
tight: PropTypes.bool,
numberOfLines: PropTypes.number
};
List.defaultProps = {
start: 1
};
export default List; export default List;

View File

@ -1,10 +1,5 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { StyleSheet, Text, View } from 'react-native';
import {
StyleSheet,
Text,
View
} from 'react-native';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
@ -22,9 +17,19 @@ const style = StyleSheet.create({
} }
}); });
interface IListItem {
children: JSX.Element;
bulletWidth: number;
level: number;
ordered: boolean;
continue: boolean;
theme: string;
index: number;
}
const ListItem = React.memo(({ const ListItem = React.memo(({
children, level, bulletWidth, continue: _continue, ordered, index, theme children, level, bulletWidth, continue: _continue, ordered, index, theme
}) => { }: IListItem) => {
let bullet; let bullet;
if (_continue) { if (_continue) {
bullet = ''; bullet = '';
@ -50,14 +55,4 @@ const ListItem = React.memo(({
); );
}); });
ListItem.propTypes = {
children: PropTypes.node,
bulletWidth: PropTypes.number,
level: PropTypes.number,
ordered: PropTypes.bool,
continue: PropTypes.bool,
theme: PropTypes.string,
index: PropTypes.number
};
export default ListItem; export default ListItem;

View File

@ -1,11 +1,5 @@
import { PropTypes } from 'prop-types';
import React from 'react'; import React from 'react';
import { import { ScrollView, TouchableOpacity, View, Text } from 'react-native';
ScrollView,
TouchableOpacity,
View,
Text
} from 'react-native';
import { CELL_WIDTH } from './TableCell'; import { CELL_WIDTH } from './TableCell';
import styles from './styles'; import styles from './styles';
@ -13,20 +7,24 @@ import Navigation from '../../lib/Navigation';
import I18n from '../../i18n'; import I18n from '../../i18n';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
interface ITable {
children: JSX.Element;
numColumns: number;
theme: string;
}
const MAX_HEIGHT = 300; const MAX_HEIGHT = 300;
const Table = React.memo(({ const Table = React.memo(({ children, numColumns, theme }: ITable) => {
children, numColumns, theme
}) => {
const getTableWidth = () => numColumns * CELL_WIDTH; const getTableWidth = () => numColumns * CELL_WIDTH;
const renderRows = (drawExtraBorders = true) => { const renderRows = (drawExtraBorders: boolean = true) => {
const tableStyle = [styles.table, { borderColor: themes[theme].borderColor }]; const tableStyle = [styles.table, { borderColor: themes[theme].borderColor }];
if (drawExtraBorders) { if (drawExtraBorders) {
tableStyle.push(styles.tableExtraBorders); tableStyle.push(styles.tableExtraBorders);
} }
const rows = React.Children.toArray(children); const rows: any = React.Children.toArray(children);
rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], { rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], {
isLastRow: true isLastRow: true
}); });
@ -55,10 +53,4 @@ const Table = React.memo(({
); );
}); });
Table.propTypes = {
children: PropTypes.node.isRequired,
numColumns: PropTypes.number.isRequired,
theme: PropTypes.string
};
export default Table; export default Table;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { Text, View } from 'react-native'; import { Text, View } from 'react-native';
@ -6,11 +5,16 @@ import { themes } from '../../constants/colors';
import styles from './styles'; import styles from './styles';
interface ITableCell {
align: '' | 'left' | 'center' | 'right';
children: JSX.Element;
isLastCell: boolean;
theme: string;
}
export const CELL_WIDTH = 100; export const CELL_WIDTH = 100;
const TableCell = React.memo(({ const TableCell = React.memo(({ isLastCell, align, children, theme }: ITableCell) => {
isLastCell, align, children, theme
}) => {
const cellStyle = [styles.cell, { borderColor: themes[theme].borderColor }]; const cellStyle = [styles.cell, { borderColor: themes[theme].borderColor }];
if (!isLastCell) { if (!isLastCell) {
cellStyle.push(styles.cellRightBorder); cellStyle.push(styles.cellRightBorder);
@ -32,11 +36,4 @@ const TableCell = React.memo(({
); );
}); });
TableCell.propTypes = {
align: PropTypes.oneOf(['', 'left', 'center', 'right']),
children: PropTypes.node,
isLastCell: PropTypes.bool,
theme: PropTypes.string
};
export default TableCell; export default TableCell;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
@ -6,15 +5,19 @@ import { themes } from '../../constants/colors';
import styles from './styles'; import styles from './styles';
const TableRow = React.memo(({ interface ITableRow {
isLastRow, children: _children, theme children: JSX.Element;
}) => { isLastRow: boolean;
theme: string;
}
const TableRow = React.memo(({ isLastRow, children: _children, theme }: ITableRow) => {
const rowStyle = [styles.row, { borderColor: themes[theme].borderColor }]; const rowStyle = [styles.row, { borderColor: themes[theme].borderColor }];
if (!isLastRow) { if (!isLastRow) {
rowStyle.push(styles.rowBottomBorder); rowStyle.push(styles.rowBottomBorder);
} }
const children = React.Children.toArray(_children); const children: any = React.Children.toArray(_children);
children[children.length - 1] = React.cloneElement(children[children.length - 1], { children[children.length - 1] = React.cloneElement(children[children.length - 1], {
isLastCell: true isLastCell: true
}); });
@ -22,10 +25,4 @@ const TableRow = React.memo(({
return <View style={rowStyle}>{children}</View>; return <View style={rowStyle}>{children}</View>;
}); });
TableRow.propTypes = {
children: PropTypes.node,
isLastRow: PropTypes.bool,
theme: PropTypes.string
};
export default TableRow; export default TableRow;

View File

@ -2,7 +2,6 @@ import React, { PureComponent } from 'react';
import { Text, Image } from 'react-native'; import { Text, Image } from 'react-native';
import { Parser, Node } from 'commonmark'; import { Parser, Node } from 'commonmark';
import Renderer from 'commonmark-react-renderer'; import Renderer from 'commonmark-react-renderer';
import PropTypes from 'prop-types';
import removeMarkdown from 'remove-markdown'; import removeMarkdown from 'remove-markdown';
import shortnameToUnicode from '../../utils/shortnameToUnicode'; import shortnameToUnicode from '../../utils/shortnameToUnicode';
@ -13,7 +12,7 @@ import MarkdownLink from './Link';
import MarkdownList from './List'; import MarkdownList from './List';
import MarkdownListItem from './ListItem'; import MarkdownListItem from './ListItem';
import MarkdownAtMention from './AtMention'; import MarkdownAtMention from './AtMention';
import MarkdownHashtag from './Hashtag'; import MarkdownHashtag, {TChannel} from './Hashtag';
import MarkdownBlockQuote from './BlockQuote'; import MarkdownBlockQuote from './BlockQuote';
import MarkdownEmoji from './Emoji'; import MarkdownEmoji from './Emoji';
import MarkdownTable from './Table'; import MarkdownTable from './Table';
@ -24,8 +23,32 @@ import mergeTextNodes from './mergeTextNodes';
import styles from './styles'; import styles from './styles';
import { isValidURL } from '../../utils/url'; import { isValidURL } from '../../utils/url';
interface IMarkdownProps {
msg: string;
getCustomEmoji: Function;
baseUrl: string;
username: string;
tmid: string;
isEdited: boolean;
numberOfLines: number;
customEmojis: boolean;
useRealName: boolean;
channels: TChannel[];
mentions: object[];
navToRoomInfo: Function;
preview: boolean;
theme: string;
testID: string;
style: any;
onLinkPress: Function;
}
type TLiteral = {
literal: string;
}
// Support <http://link|Text> // Support <http://link|Text>
const formatText = text => text.replace( const formatText = (text: string) => text.replace(
new RegExp('(?:<|<)((?:https|http):\\/\\/[^\\|]+)\\|(.+?)(?=>|>)(?:>|>)', 'gm'), new RegExp('(?:<|<)((?:https|http):\\/\\/[^\\|]+)\\|(.+?)(?=>|>)(?:>|>)', 'gm'),
(match, url, title) => `[${ title }](${ url })` (match, url, title) => `[${ title }](${ url })`
); );
@ -36,18 +59,18 @@ const emojiRanges = [
' |\n' // allow spaces and line breaks ' |\n' // allow spaces and line breaks
].join('|'); ].join('|');
const removeSpaces = str => str && str.replace(/\s/g, ''); const removeSpaces = (str: string) => str && str.replace(/\s/g, '');
const removeAllEmoji = str => str.replace(new RegExp(emojiRanges, 'g'), ''); const removeAllEmoji = (str: string) => str.replace(new RegExp(emojiRanges, 'g'), '');
const isOnlyEmoji = (str) => { const isOnlyEmoji = (str: string) => {
str = removeSpaces(str); str = removeSpaces(str);
return !removeAllEmoji(str).length; return !removeAllEmoji(str).length;
}; };
const removeOneEmoji = str => str.replace(new RegExp(emojiRanges), ''); const removeOneEmoji = (str: string) => str.replace(new RegExp(emojiRanges), '');
const emojiCount = (str) => { const emojiCount = (str: string) => {
str = removeSpaces(str); str = removeSpaces(str);
let oldLength = 0; let oldLength = 0;
let counter = 0; let counter = 0;
@ -65,28 +88,11 @@ const emojiCount = (str) => {
const parser = new Parser(); const parser = new Parser();
class Markdown extends PureComponent { class Markdown extends PureComponent<IMarkdownProps, any> {
static propTypes = { private renderer: any;
msg: PropTypes.string, private isMessageContainsOnlyEmoji!: boolean;
getCustomEmoji: PropTypes.func,
baseUrl: PropTypes.string,
username: PropTypes.string,
tmid: PropTypes.string,
isEdited: PropTypes.bool,
numberOfLines: PropTypes.number,
customEmojis: PropTypes.bool,
useRealName: PropTypes.bool,
channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
navToRoomInfo: PropTypes.func,
preview: PropTypes.bool,
theme: PropTypes.string,
testID: PropTypes.string,
style: PropTypes.array,
onLinkPress: PropTypes.func
};
constructor(props) { constructor(props: IMarkdownProps) {
super(props); super(props);
this.renderer = this.createRenderer(); this.renderer = this.createRenderer();
} }
@ -129,7 +135,7 @@ class Markdown extends PureComponent {
renderParagraphsInLists: true renderParagraphsInLists: true
}); });
editedMessage = (ast) => { editedMessage = (ast: any) => {
const { isEdited } = this.props; const { isEdited } = this.props;
if (isEdited) { if (isEdited) {
const editIndicatorNode = new Node('edited_indicator'); const editIndicatorNode = new Node('edited_indicator');
@ -144,7 +150,7 @@ class Markdown extends PureComponent {
} }
}; };
renderText = ({ context, literal }) => { renderText = ({ context, literal }: {context: []; literal: string}) => {
const { const {
numberOfLines, style = [] numberOfLines, style = []
} = this.props; } = this.props;
@ -163,7 +169,7 @@ class Markdown extends PureComponent {
); );
} }
renderCodeInline = ({ literal }) => { renderCodeInline = ({ literal }: TLiteral) => {
const { theme, style = [] } = this.props; const { theme, style = [] } = this.props;
return ( return (
<Text <Text
@ -182,7 +188,7 @@ class Markdown extends PureComponent {
); );
}; };
renderCodeBlock = ({ literal }) => { renderCodeBlock = ({ literal }: TLiteral) => {
const { theme, style = [] } = this.props; const { theme, style = [] } = this.props;
return ( return (
<Text <Text
@ -206,7 +212,7 @@ class Markdown extends PureComponent {
return <Text>{tmid ? ' ' : '\n'}</Text>; return <Text>{tmid ? ' ' : '\n'}</Text>;
} }
renderParagraph = ({ children }) => { renderParagraph = ({ children }: any) => {
const { numberOfLines, style, theme } = this.props; const { numberOfLines, style, theme } = this.props;
if (!children || children.length === 0) { if (!children || children.length === 0) {
return null; return null;
@ -218,7 +224,7 @@ class Markdown extends PureComponent {
); );
}; };
renderLink = ({ children, href }) => { renderLink = ({ children, href }: any) => {
const { theme, onLinkPress } = this.props; const { theme, onLinkPress } = this.props;
return ( return (
<MarkdownLink <MarkdownLink
@ -231,10 +237,8 @@ class Markdown extends PureComponent {
); );
} }
renderHashtag = ({ hashtag }) => { renderHashtag = ({ hashtag }: {hashtag: string}) => {
const { const { channels, navToRoomInfo, style, theme } = this.props;
channels, navToRoomInfo, style, theme
} = this.props;
return ( return (
<MarkdownHashtag <MarkdownHashtag
hashtag={hashtag} hashtag={hashtag}
@ -246,10 +250,8 @@ class Markdown extends PureComponent {
); );
} }
renderAtMention = ({ mentionName }) => { renderAtMention = ({ mentionName }: { mentionName: string }) => {
const { const { username, mentions, navToRoomInfo, useRealName, style, theme } = this.props;
username, mentions, navToRoomInfo, useRealName, style, theme
} = this.props;
return ( return (
<MarkdownAtMention <MarkdownAtMention
mentions={mentions} mentions={mentions}
@ -263,10 +265,8 @@ class Markdown extends PureComponent {
); );
} }
renderEmoji = ({ literal }) => { renderEmoji = ({ literal }: TLiteral) => {
const { const { getCustomEmoji, baseUrl, customEmojis, style, theme } = this.props;
getCustomEmoji, baseUrl, customEmojis, style, theme
} = this.props;
return ( return (
<MarkdownEmoji <MarkdownEmoji
literal={literal} literal={literal}
@ -280,7 +280,7 @@ class Markdown extends PureComponent {
); );
} }
renderImage = ({ src }) => { renderImage = ({ src }: {src: string}) => {
if (!isValidURL(src)) { if (!isValidURL(src)) {
return null; return null;
} }
@ -298,7 +298,7 @@ class Markdown extends PureComponent {
return <Text style={[styles.edited, { color: themes[theme].auxiliaryText }]}> ({I18n.t('edited')})</Text>; return <Text style={[styles.edited, { color: themes[theme].auxiliaryText }]}> ({I18n.t('edited')})</Text>;
} }
renderHeading = ({ children, level }) => { renderHeading = ({ children, level }: any) => {
const { numberOfLines, theme } = this.props; const { numberOfLines, theme } = this.props;
const textStyle = styles[`heading${ level }Text`]; const textStyle = styles[`heading${ level }Text`];
return ( return (
@ -308,9 +308,7 @@ class Markdown extends PureComponent {
); );
}; };
renderList = ({ renderList = ({ children, start, tight, type }: any) => {
children, start, tight, type
}) => {
const { numberOfLines } = this.props; const { numberOfLines } = this.props;
return ( return (
<MarkdownList <MarkdownList
@ -324,11 +322,9 @@ class Markdown extends PureComponent {
); );
}; };
renderListItem = ({ renderListItem = ({ children, context, ...otherProps }: any) => {
children, context, ...otherProps
}) => {
const { theme } = this.props; const { theme } = this.props;
const level = context.filter(type => type === 'list').length; const level = context.filter((type: string) => type === 'list').length;
return ( return (
<MarkdownListItem <MarkdownListItem
@ -341,7 +337,7 @@ class Markdown extends PureComponent {
); );
}; };
renderBlockQuote = ({ children }) => { renderBlockQuote = ({ children }: {children: JSX.Element}) => {
const { theme } = this.props; const { theme } = this.props;
return ( return (
<MarkdownBlockQuote theme={theme}> <MarkdownBlockQuote theme={theme}>
@ -350,7 +346,7 @@ class Markdown extends PureComponent {
); );
} }
renderTable = ({ children, numColumns }) => { renderTable = ({ children, numColumns }: { children: JSX.Element; numColumns: number }) => {
const { theme } = this.props; const { theme } = this.props;
return ( return (
<MarkdownTable numColumns={numColumns} theme={theme}> <MarkdownTable numColumns={numColumns} theme={theme}>
@ -359,12 +355,12 @@ class Markdown extends PureComponent {
); );
} }
renderTableRow = (args) => { renderTableRow = (args: any) => {
const { theme } = this.props; const { theme } = this.props;
return <MarkdownTableRow {...args} theme={theme} />; return <MarkdownTableRow {...args} theme={theme} />;
} }
renderTableCell = (args) => { renderTableCell = (args: any) => {
const { theme } = this.props; const { theme } = this.props;
return <MarkdownTableCell {...args} theme={theme} />; return <MarkdownTableCell {...args} theme={theme} />;
} }

View File

@ -1,6 +1,6 @@
// TODO: should we add this to our commonmark fork instead? // TODO: should we add this to our commonmark fork instead?
// we loop through nodes and try to merge all texts // we loop through nodes and try to merge all texts
export default function mergeTextNodes(ast) { export default function mergeTextNodes(ast: any) {
// https://github.com/commonmark/commonmark.js/blob/master/lib/node.js#L268 // https://github.com/commonmark/commonmark.js/blob/master/lib/node.js#L268
const walker = ast.walker(); const walker = ast.walker();
let event; let event;

View File

@ -7,7 +7,7 @@ const codeFontFamily = Platform.select({
android: { fontFamily: 'monospace' } android: { fontFamily: 'monospace' }
}); });
export default StyleSheet.create({ export default StyleSheet.create<any>({
container: { container: {
alignItems: 'flex-start', alignItems: 'flex-start',
flexDirection: 'row' flexDirection: 'row'