1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Chore: Mobile: Migrate the tags dialog to TypeScript (#10185)

This commit is contained in:
Henry Heino 2024-03-23 07:21:37 -07:00 committed by GitHub
parent 0331d2a8db
commit b5a16f756a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 272 additions and 225 deletions

View File

@ -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

2
.gitignore vendored
View File

@ -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

View File

@ -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<Props, State> {
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<string, ViewStyle|TextStyle> = {
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;

View File

@ -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';

View File

@ -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 (
<TouchableOpacity key={tag.id} onPress={() => this.tag_press(tag.id)} style={this.styles().tag}>
<View style={this.styles().tagIconText}>
<Icon name={iconName} style={this.styles().tagCheckbox} />
<Text style={this.styles().tagText}>{tag.title}</Text>
</View>
</TouchableOpacity>
);
};
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 = (
<View style={{ flex: 1 }}>
<View style={this.styles().tagBox}>
<Text style={this.styles().newTagBoxLabel}>{_('New tags:')}</Text>
<TextInput
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
value={this.state.newTags}
onChangeText={value => {
this.setState({ newTags: value });
}}
style={this.styles().tagBoxInput}
placeholder={_('tag1, tag2, ...')}
/>
</View>
<View style={this.styles().tagBox}>
<TextInput
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
value={this.state.tagFilter}
onChangeText={value => {
this.setState({ tagFilter: value });
}}
placeholder={_('Filter tags')}
style={this.styles().tagBoxInput}
/>
</View>
<FlatList data={this.filterTags(this.state.tagListData)} renderItem={this.renderTag} keyExtractor={this.tagKeyExtractor} />
</View>
);
return <ModalDialog themeId={this.props.themeId} ContentComponent={dialogContent} title={_('Type new tags or select from list')} onOkPress={this.okButton_press} onCancelPress={this.cancelButton_press} buttonBarEnabled={!this.state.savingTags} />;
}
}
const NoteTagsDialog = connect(state => {
return {
themeId: state.settings.theme,
tags: state.tags,
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
};
})(NoteTagsDialogComponent);
module.exports = NoteTagsDialog;

View File

@ -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<Props, State> {
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 (
<TouchableOpacity key={tag.id} onPress={() => this.tag_press(tag.id)} style={this.styles().tag}>
<View style={this.styles().tagIconText}>
<Icon name={iconName} style={this.styles().tagCheckbox} />
<Text style={this.styles().tagText}>{tag.title}</Text>
</View>
</TouchableOpacity>
);
};
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 = (
<View style={{ flex: 1 }}>
<View style={this.styles().tagBox}>
<Text style={this.styles().newTagBoxLabel}>{_('New tags:')}</Text>
<TextInput
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
value={this.state.newTags}
onChangeText={value => {
this.setState({ newTags: value });
}}
style={this.styles().tagBoxInput}
placeholder={_('tag1, tag2, ...')}
/>
</View>
<View style={this.styles().tagBox}>
<TextInput
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
value={this.state.tagFilter}
onChangeText={value => {
this.setState({ tagFilter: value });
}}
placeholder={_('Filter tags')}
style={this.styles().tagBoxInput}
/>
</View>
<FlatList data={this.filterTags(this.state.tagListData)} renderItem={this.renderTag} keyExtractor={this.tagKeyExtractor} />
</View>
);
return <ModalDialog themeId={this.props.themeId} ContentComponent={dialogContent} title={_('Type new tags or select from list')} onOkPress={this.okButton_press} onCancelPress={this.cancelButton_press} buttonBarEnabled={!this.state.savingTags} />;
}
}
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;