diff --git a/.eslintignore b/.eslintignore index 0df74ed12..3e35788f4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -496,6 +496,7 @@ packages/app-mobile/components/ExtendedWebView.js packages/app-mobile/components/FolderPicker.js packages/app-mobile/components/Icon.js packages/app-mobile/components/Modal.js +packages/app-mobile/components/ModalDialog.js packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js @@ -592,6 +593,7 @@ packages/app-mobile/components/screens/ConfigScreen/types.js packages/app-mobile/components/screens/JoplinCloudLoginScreen.js packages/app-mobile/components/screens/LogScreen.js packages/app-mobile/components/screens/Note.js +packages/app-mobile/components/screens/NoteTagsDialog.js packages/app-mobile/components/screens/Notes.js packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js packages/app-mobile/components/screens/encryption-config.js diff --git a/.gitignore b/.gitignore index 21bb9c71c..03c3ad65f 100644 --- a/.gitignore +++ b/.gitignore @@ -476,6 +476,7 @@ packages/app-mobile/components/ExtendedWebView.js packages/app-mobile/components/FolderPicker.js packages/app-mobile/components/Icon.js packages/app-mobile/components/Modal.js +packages/app-mobile/components/ModalDialog.js packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js @@ -572,6 +573,7 @@ packages/app-mobile/components/screens/ConfigScreen/types.js packages/app-mobile/components/screens/JoplinCloudLoginScreen.js packages/app-mobile/components/screens/LogScreen.js packages/app-mobile/components/screens/Note.js +packages/app-mobile/components/screens/NoteTagsDialog.js packages/app-mobile/components/screens/Notes.js packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js packages/app-mobile/components/screens/encryption-config.js diff --git a/packages/app-mobile/components/ModalDialog.js b/packages/app-mobile/components/ModalDialog.tsx similarity index 72% rename from packages/app-mobile/components/ModalDialog.js rename to packages/app-mobile/components/ModalDialog.tsx index 483515a53..3653cb408 100644 --- a/packages/app-mobile/components/ModalDialog.js +++ b/packages/app-mobile/components/ModalDialog.tsx @@ -1,24 +1,41 @@ -const React = require('react'); -const { Text, View, StyleSheet, Button } = require('react-native'); -const { themeStyle } = require('./global-style'); -const { _ } = require('@joplin/lib/locale'); +import * as React from 'react'; +import { ReactNode } from 'react'; +import { Text, View, StyleSheet, Button, TextStyle, ViewStyle } from 'react-native'; +import { themeStyle } from './global-style'; +import { _ } from '@joplin/lib/locale'; import Modal from './Modal'; -class ModalDialog extends React.Component { - constructor() { - super(); +interface Props { + themeId: number; + ContentComponent: ReactNode; + + buttonBarEnabled: boolean; + title: string; + onOkPress: ()=> void; + onCancelPress: ()=> void; +} + +interface State { + +} + +class ModalDialog extends React.Component { + private styles_: any; + + public constructor(props: Props) { + super(props); this.styles_ = {}; } - styles() { + private styles() { const themeId = this.props.themeId; const theme = themeStyle(themeId); if (this.styles_[themeId]) return this.styles_[themeId]; this.styles_ = {}; - const styles = { + const styles: Record = { modalWrapper: { flex: 1, justifyContent: 'center', @@ -53,7 +70,7 @@ class ModalDialog extends React.Component { return this.styles_[themeId]; } - render() { + public override render() { const ContentComponent = this.props.ContentComponent; const buttonBarEnabled = this.props.buttonBarEnabled !== false; @@ -76,4 +93,4 @@ class ModalDialog extends React.Component { } } -module.exports = ModalDialog; +export default ModalDialog; diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index dde1b495d..c68ed0437 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -26,7 +26,7 @@ import ActionButton from '../ActionButton'; const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils'); const mimeUtils = require('@joplin/lib/mime-utils.js').mime; import ScreenHeader, { MenuOptionType } from '../ScreenHeader'; -const NoteTagsDialog = require('./NoteTagsDialog'); +import NoteTagsDialog from './NoteTagsDialog'; import time from '@joplin/lib/time'; const { Checkbox } = require('../checkbox.js'); import { _, currentLocale } from '@joplin/lib/locale'; diff --git a/packages/app-mobile/components/screens/NoteTagsDialog.js b/packages/app-mobile/components/screens/NoteTagsDialog.js deleted file mode 100644 index 0078eb150..000000000 --- a/packages/app-mobile/components/screens/NoteTagsDialog.js +++ /dev/null @@ -1,213 +0,0 @@ -const React = require('react'); - -const { StyleSheet, View, Text, FlatList, TouchableOpacity, TextInput } = require('react-native'); -const { connect } = require('react-redux'); -const Tag = require('@joplin/lib/models/Tag').default; -const { _ } = require('@joplin/lib/locale'); -const { themeStyle } = require('../global-style'); -const Icon = require('react-native-vector-icons/Ionicons').default; -const ModalDialog = require('../ModalDialog'); -const naturalCompare = require('string-natural-compare'); - -// We need this to suppress the useless warning -// https://github.com/oblador/react-native-vector-icons/issues/1465 -// eslint-disable-next-line no-console -Icon.loadFont().catch((error) => { console.info(error); }); - -class NoteTagsDialogComponent extends React.Component { - constructor() { - super(); - this.styles_ = {}; - this.state = { - noteTagIds: [], - noteId: null, - tagListData: [], - newTags: '', - savingTags: false, - tagFilter: '', - }; - - const noteHasTag = tagId => { - for (let i = 0; i < this.state.tagListData.length; i++) { - if (this.state.tagListData[i].id === tagId) return this.state.tagListData[i].selected; - } - return false; - }; - - const newTagTitles = () => { - return this.state.newTags - .split(',') - .map(t => t.trim().toLowerCase()) - .filter(t => !!t); - }; - - this.tag_press = tagId => { - const newData = this.state.tagListData.slice(); - for (let i = 0; i < newData.length; i++) { - const t = newData[i]; - if (t.id === tagId) { - const newTag = { ...t }; - newTag.selected = !newTag.selected; - newData[i] = newTag; - break; - } - } - - this.setState({ tagListData: newData }); - }; - - this.renderTag = data => { - const tag = data.item; - const iconName = noteHasTag(tag.id) ? 'checkbox-outline' : 'square-outline'; - return ( - this.tag_press(tag.id)} style={this.styles().tag}> - - - {tag.title} - - - ); - }; - - this.tagKeyExtractor = (tag) => tag.id; - - this.okButton_press = async () => { - this.setState({ savingTags: true }); - - try { - const tagIds = this.state.tagListData.filter(t => t.selected).map(t => t.id); - await Tag.setNoteTagsByIds(this.state.noteId, tagIds); - - const extraTitles = newTagTitles(); - for (let i = 0; i < extraTitles.length; i++) { - await Tag.addNoteTagByTitle(this.state.noteId, extraTitles[i]); - } - } finally { - this.setState({ savingTags: false }); - } - - if (this.props.onCloseRequested) this.props.onCloseRequested(); - }; - - this.cancelButton_press = () => { - if (this.props.onCloseRequested) this.props.onCloseRequested(); - }; - - this.filterTags = (allTags) => { - return allTags.filter((tag) => tag.title.toLowerCase().includes(this.state.tagFilter.toLowerCase()), allTags); - }; - } - - UNSAFE_componentWillMount() { - const noteId = this.props.noteId; - this.setState({ noteId: noteId }); - this.loadNoteTags(noteId); - } - - async loadNoteTags(noteId) { - const tags = await Tag.tagsByNoteId(noteId); - const tagIds = tags.map(t => t.id); - - const tagListData = this.props.tags.map(tag => { - return { - id: tag.id, - title: tag.title, - selected: tagIds.indexOf(tag.id) >= 0, - }; - }); - - tagListData.sort((a, b) => { - if (a.selected === b.selected) return naturalCompare(a.title, b.title, { caseInsensitive: true }); - else if (b.selected === true) return 1; - else return -1; - }); - - this.setState({ tagListData: tagListData }); - } - - styles() { - const themeId = this.props.themeId; - const theme = themeStyle(themeId); - - if (this.styles_[themeId]) return this.styles_[themeId]; - this.styles_ = {}; - - const styles = { - tag: { - padding: 10, - borderBottomWidth: 1, - borderBottomColor: theme.dividerColor, - }, - tagIconText: { - flexDirection: 'row', - alignItems: 'center', - }, - tagText: { ...theme.normalText }, - tagCheckbox: { - marginRight: 8, - fontSize: 20, - color: theme.color, - }, - tagBox: { - flexDirection: 'row', - alignItems: 'center', - paddingLeft: 10, - paddingRight: 10, - borderBottomWidth: 1, - borderBottomColor: theme.dividerColor, - }, - newTagBoxLabel: { ...theme.normalText, marginRight: 8 }, - tagBoxInput: { ...theme.lineInput, flex: 1 }, - }; - - this.styles_[themeId] = StyleSheet.create(styles); - return this.styles_[themeId]; - } - - render() { - const theme = themeStyle(this.props.themeId); - - const dialogContent = ( - - - {_('New tags:')} - { - this.setState({ newTags: value }); - }} - style={this.styles().tagBoxInput} - placeholder={_('tag1, tag2, ...')} - /> - - - { - this.setState({ tagFilter: value }); - }} - placeholder={_('Filter tags')} - style={this.styles().tagBoxInput} - /> - - - - ); - - return ; - } -} - -const NoteTagsDialog = connect(state => { - return { - themeId: state.settings.theme, - tags: state.tags, - noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, - }; -})(NoteTagsDialogComponent); - -module.exports = NoteTagsDialog; diff --git a/packages/app-mobile/components/screens/NoteTagsDialog.tsx b/packages/app-mobile/components/screens/NoteTagsDialog.tsx new file mode 100644 index 000000000..e3e1cdafe --- /dev/null +++ b/packages/app-mobile/components/screens/NoteTagsDialog.tsx @@ -0,0 +1,239 @@ +import * as React from 'react'; + +import { StyleSheet, View, Text, FlatList, TouchableOpacity, TextInput } from 'react-native'; +import { connect } from 'react-redux'; +import Tag from '@joplin/lib/models/Tag'; +import { _ } from '@joplin/lib/locale'; +import { themeStyle } from '../global-style'; +const Icon = require('react-native-vector-icons/Ionicons').default; +import ModalDialog from '../ModalDialog'; +import { AppState } from '../../utils/types'; +import { TagEntity } from '@joplin/lib/services/database/types'; +const naturalCompare = require('string-natural-compare'); + +// We need this to suppress the useless warning +// https://github.com/oblador/react-native-vector-icons/issues/1465 +// eslint-disable-next-line no-console +Icon.loadFont().catch((error: any) => { console.info(error); }); + +interface Props { + themeId: number; + noteId: string|null; + onCloseRequested?: ()=> void; + tags: TagEntity[]; +} + +interface TagListRecord { + id: string; + title: string; + selected: boolean; +} + +interface State { + noteTagIds: string[]; + tagListData: TagListRecord[]; + noteId: string|null; + newTags: string; + savingTags: boolean; + tagFilter: string; +} + +class NoteTagsDialogComponent extends React.Component { + private styles_: any; + + public constructor(props: Props) { + super(props); + this.styles_ = {}; + this.state = { + noteTagIds: [], + noteId: null, + tagListData: [], + newTags: '', + savingTags: false, + tagFilter: '', + }; + } + + private noteHasTag(tagId: string) { + for (let i = 0; i < this.state.tagListData.length; i++) { + if (this.state.tagListData[i].id === tagId) return this.state.tagListData[i].selected; + } + return false; + } + + private newTagTitles() { + return this.state.newTags + .split(',') + .map(t => t.trim().toLowerCase()) + .filter(t => !!t); + } + + private tag_press = (tagId: string) => { + const newData = this.state.tagListData.slice(); + for (let i = 0; i < newData.length; i++) { + const t = newData[i]; + if (t.id === tagId) { + const newTag = { ...t }; + newTag.selected = !newTag.selected; + newData[i] = newTag; + break; + } + } + + this.setState({ tagListData: newData }); + }; + + private renderTag = (data: { item: TagListRecord }) => { + const tag = data.item; + const iconName = this.noteHasTag(tag.id) ? 'checkbox-outline' : 'square-outline'; + return ( + this.tag_press(tag.id)} style={this.styles().tag}> + + + {tag.title} + + + ); + }; + + private tagKeyExtractor = (tag: TagListRecord) => tag.id; + + private okButton_press = async () => { + this.setState({ savingTags: true }); + + try { + const tagIds = this.state.tagListData.filter(t => t.selected).map(t => t.id); + await Tag.setNoteTagsByIds(this.state.noteId, tagIds); + + const extraTitles = this.newTagTitles(); + for (let i = 0; i < extraTitles.length; i++) { + await Tag.addNoteTagByTitle(this.state.noteId, extraTitles[i]); + } + } finally { + this.setState({ savingTags: false }); + } + + if (this.props.onCloseRequested) this.props.onCloseRequested(); + }; + + private cancelButton_press = () => { + if (this.props.onCloseRequested) this.props.onCloseRequested(); + }; + + private filterTags(allTags: TagListRecord[]) { + return allTags.filter((tag) => tag.title.toLowerCase().includes(this.state.tagFilter.toLowerCase()), allTags); + } + + public override UNSAFE_componentWillMount() { + const noteId = this.props.noteId; + this.setState({ noteId: noteId }); + void this.loadNoteTags(noteId); + } + + private async loadNoteTags(noteId: string) { + const tags = await Tag.tagsByNoteId(noteId); + const tagIds = tags.map(t => t.id); + + const tagListData = this.props.tags.map(tag => { + return { + id: tag.id, + title: tag.title, + selected: tagIds.indexOf(tag.id) >= 0, + }; + }); + + tagListData.sort((a, b) => { + if (a.selected === b.selected) return naturalCompare(a.title, b.title, { caseInsensitive: true }); + else if (b.selected === true) return 1; + else return -1; + }); + + this.setState({ tagListData: tagListData }); + } + + private styles() { + const themeId = this.props.themeId; + const theme = themeStyle(themeId); + + if (this.styles_[themeId]) return this.styles_[themeId]; + this.styles_ = {}; + + const styles = StyleSheet.create({ + tag: { + padding: 10, + borderBottomWidth: 1, + borderBottomColor: theme.dividerColor, + }, + tagIconText: { + flexDirection: 'row', + alignItems: 'center', + }, + tagText: { ...theme.normalText }, + tagCheckbox: { + marginRight: 8, + fontSize: 20, + color: theme.color, + }, + tagBox: { + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 10, + paddingRight: 10, + borderBottomWidth: 1, + borderBottomColor: theme.dividerColor, + }, + newTagBoxLabel: { ...theme.normalText, marginRight: 8 }, + tagBoxInput: { ...theme.lineInput, flex: 1 }, + }); + + this.styles_[themeId] = styles; + return this.styles_[themeId]; + } + + public override render() { + const theme = themeStyle(this.props.themeId); + + const dialogContent = ( + + + {_('New tags:')} + { + this.setState({ newTags: value }); + }} + style={this.styles().tagBoxInput} + placeholder={_('tag1, tag2, ...')} + /> + + + { + this.setState({ tagFilter: value }); + }} + placeholder={_('Filter tags')} + style={this.styles().tagBoxInput} + /> + + + + ); + + return ; + } +} + +const NoteTagsDialog = connect((state: AppState) => { + return { + themeId: state.settings.theme, + tags: state.tags, + noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, + }; +})(NoteTagsDialogComponent); + +export default NoteTagsDialog;