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:
parent
0331d2a8db
commit
b5a16f756a
@ -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
2
.gitignore
vendored
@ -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
|
||||
|
@ -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;
|
@ -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';
|
||||
|
@ -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;
|
239
packages/app-mobile/components/screens/NoteTagsDialog.tsx
Normal file
239
packages/app-mobile/components/screens/NoteTagsDialog.tsx
Normal 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;
|
Loading…
Reference in New Issue
Block a user