src/views/article-create/article-create.tsx (398 lines of code) (raw):

import React, {useCallback, useContext, useEffect, useState} from 'react'; import { ActivityIndicator, ScrollView, View, Text, TouchableOpacity, KeyboardAvoidingView, } from 'react-native'; import {useDebouncedCallback} from 'use-debounce'; import {useSelector} from 'react-redux'; import {useDispatch} from 'hooks/use-dispatch'; import {View as AnimatedView} from 'react-native-animatable'; import * as articleCreateActions from './arcticle-create-actions'; import AttachFileDialog from 'components/attach-file/attach-file-dialog'; import AttachmentAddPanel from 'components/attachments-row/attachments-add-panel'; import AttachmentsRow from 'components/attachments-row/attachments-row'; import Badge from 'components/badge/badge'; import Header from 'components/header/header'; import IssuePermissions from 'components/issue-permissions/issue-permissions'; import Router from 'components/router/router'; import Select from 'components/select/select'; import Separator from 'components/separator/separator'; import SummaryDescriptionForm from 'components/form/summary-description-form'; import VisibilityControl from 'components/visibility/visibility-control'; import {ANALYTICS_ARTICLE_CREATE_PAGE} from 'components/analytics/analytics-ids'; import {getApi} from 'components/api/api__instance'; import {getStorageState} from 'components/storage/storage'; import {headerMinHeight} from 'components/header/header.styles'; import {i18n} from 'components/i18n/i18n'; import {IconAngleDown, IconCheck, IconClose} from 'components/icon/icon'; import {isIOSPlatform} from 'util/util'; import {PanelWithSeparator} from 'components/panel/panel-with-separator'; import {SkeletonCreateArticle} from 'components/skeleton/skeleton'; import {ThemeContext} from 'components/theme/theme-context'; import styles from './article-create.styles'; import type {AppState} from 'reducers'; import type {Article, ArticleDraft, ArticleProject} from 'types/Article'; import type {ArticleCreateState} from './article-create-reducers'; import type {Attachment} from 'types/CustomFields'; import type {CustomError} from 'types/Error'; import type {NormalizedAttachment} from 'types/Attachment'; import type {Theme, UIThemeColors} from 'types/Theme'; import type {Visibility} from 'types/Visibility'; interface Props { articleDraft: (Article & { project: ArticleProject | null; }) | null; isNew?: boolean; originalArticleIdReadable?: string; originalArticleId?: string; breadCrumbs?: React.ReactElement<React.ComponentProps<any>, any> | null; isSplitView: boolean; onHide: () => any; } const ArticleCreate = (props: Props) => { const articleDraftDataInitial = Object.freeze({ summary: '', content: '', project: { id: null, name: 'Select project', }, visibility: null, attachments: [], }); const dispatch = useDispatch(); const theme: Theme = useContext(ThemeContext); const isConnected: boolean = useSelector( (state: AppState) => !!state.app.networkState?.isConnected, ); const articleDraft: ArticleDraft | null = useSelector( (state: AppState) => state.articleCreate.articleDraft, ); const error: CustomError | null = useSelector( (state: AppState) => state.articleCreate.error, ); const isProcessing: boolean = useSelector( (state: AppState) => state.articleCreate.isProcessing, ); const issuePermissions: IssuePermissions = useSelector( (state: AppState) => state.app.issuePermissions, ); const attachingImage: Attachment | null = useSelector( (state: AppState) => state.articleCreate.attachingImage, ); const isAttachFileDialogVisible: boolean = useSelector( (state: AppState) => state.articleCreate.isAttachFileDialogVisible, ); const [isProjectSelectVisible, updateProjectSelectVisibility] = useState( false, ); const [articleDraftData, updateArticleDraftData] = useState( articleDraftDataInitial, ); const createArticleDraft = useCallback( async (articleId?: string) => await dispatch(articleCreateActions.createArticleDraft(articleId)), [dispatch], ); useEffect(() => { if (props.articleDraft) { dispatch(articleCreateActions.setDraft(props.articleDraft)); updateArticleDraftData({ attachments: props.articleDraft?.attachments, summary: props.articleDraft?.summary || articleDraftDataInitial.summary, content: props.articleDraft?.content || articleDraftDataInitial.content, project: props.articleDraft?.project || articleDraftDataInitial.project, visibility: props.articleDraft?.visibility, } as any); } else { createArticleDraft(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, createArticleDraft]); const doUpdate = async (d: ArticleDraft) => { let draft: Partial<ArticleDraft> = d; if (props.originalArticleId && !draft.id) { const createdArticleDraft = await createArticleDraft(props.originalArticleId); draft = {...createdArticleDraft, ...d}; } return dispatch(articleCreateActions.updateArticleDraft(draft)); }; const debouncedUpdate = useDebouncedCallback(doUpdate, 350); const updateDraft = (data: Record<string, any>) => { updateArticleDraftData({...articleDraftData, ...data} as any); debouncedUpdate({...articleDraft, ...data}); }; const renderProjectSelect = () => { if (isProjectSelectVisible) { const selectedItems: ArticleProject[] = []; const hideSelect = () => updateProjectSelectVisibility(false); const selectProps = { multi: false, selectedItems, emptyValue: null, placeholder: i18n('Filter projects'), dataSource: () => Promise.resolve( (getStorageState().projects || []).filter( it => issuePermissions.articleCanCreateArticle(it.ringId), ), ), onSelect: (project: ArticleProject) => { updateDraft({ project: project, parentArticle: null, visibility: null, }); hideSelect(); }, onCancel: hideSelect, }; return <Select {...selectProps} />; } }; const closeCreateArticleScreen = () => { const {onHide = () => Router.pop(true)} = props; if (!isProcessing) { onHide(); } }; const renderHeader = () => { const draft: ArticleDraft = {...articleDraft, ...articleDraftData} as any; const isSubmitDisabled: boolean = !draft.id || isProcessing || !articleDraftData.project.id || articleDraftData.summary.length === 0 || !isConnected; return ( <Header style={styles.header} title={props.isNew ? 'New Article' : 'Draft'} leftButton={ <IconClose color={isProcessing ? uiThemeColors.$disabled : linkColor} /> } onBack={() => { if (draft.id) { if (!draft.project?.id) { draft.project = null; } doUpdate(draft); } closeCreateArticleScreen(); }} rightButton={ isProcessing && articleDraft ? ( articleDraft && ( <ActivityIndicator color={theme.uiTheme.colors.$link} /> ) ) : ( <IconCheck color={isSubmitDisabled ? uiThemeColors.$disabled : linkColor} /> ) } onRightButtonClick={async () => { if (!isSubmitDisabled) { const createdArticle = await dispatch( articleCreateActions.publishArticleDraft(draft), ); if (!error) { if (props.isSplitView) { Router.KnowledgeBase({ lastVisitedArticle: createdArticle, }); } else { Router.KnowledgeBase({ preventReload: true, }); //TODO #YTM-12710. It fixes hanging after creating 2nd sub-article #YTM-12655 Router.Article({ articlePlaceholder: createdArticle, }); } } } }} /> ); }; const onAddAttachment = async ( files: NormalizedAttachment[], onAttachingFinish: () => any, ) => { await dispatch(articleCreateActions.uploadFile(files)); onAttachingFinish(); dispatch(articleCreateActions.loadAttachments()); }; const renderAttachFileDialog = (): React.ReactNode => { if (!articleDraft) { return null; } return ( <AttachFileDialog analyticsId={ANALYTICS_ARTICLE_CREATE_PAGE} hideVisibility={false} getVisibilityOptions={() => { const articlesAPI = getApi().articles; return (props.originalArticleIdReadable ? articlesAPI.getVisibilityOptions : articlesAPI.getDraftVisibilityOptions)( (props.originalArticleIdReadable || articleDraft.id) as string ); } } actions={{ onAttach: async ( files: NormalizedAttachment[], onAttachingFinish: () => any, ) => { onAddAttachment(files, onAttachingFinish); }, onCancel: () => { dispatch(articleCreateActions.cancelAddAttach(attachingImage)); dispatch(articleCreateActions.hideAddAttachDialog()); }, }} /> ); }; const renderDiscardButton = () => articleDraft?.id && ( <AnimatedView useNativeDriver duration={500} animation="fadeIn" style={styles.discard} > <TouchableOpacity style={styles.discardButton} disabled={isProcessing || !isConnected} onPress={async () => { dispatch(articleCreateActions.deleteDraft()).then(closeCreateArticleScreen); }} > <Text style={[ styles.discardButtonText, !isConnected && styles.discardButtonTextDisabled, ]} > {props.isNew ? i18n('Delete draft') : i18n('Discard unpublished changes')} </Text> </TouchableOpacity> <Separator /> </AnimatedView> ); const renderProjectPanel = () => hasArticleDraft && ( <PanelWithSeparator style={styles.projectPanel}> <View style={styles.projectContainer}> <TouchableOpacity style={styles.projectSelector} disabled={isProcessing} onPress={() => updateProjectSelectVisibility(true)} > <Text style={styles.projectSelectorText}> {articleDraftData.project.name} </Text> <IconAngleDown size={20} color={linkColor} /> </TouchableOpacity> </View> </PanelWithSeparator> ); const uiThemeColors: UIThemeColors = theme.uiTheme.colors; const linkColor: string = uiThemeColors.$link; const hasArticleDraft: boolean = articleDraft !== null; return ( <KeyboardAvoidingView behavior={isIOSPlatform() ? 'padding' : 'height'} keyboardVerticalOffset={headerMinHeight} testID="createArticle" style={styles.container} > {renderHeader()} {!hasArticleDraft && ( <View style={styles.content}> <SkeletonCreateArticle /> </View> )} {hasArticleDraft && renderProjectSelect()} <ScrollView scrollEnabled={hasArticleDraft}> {renderDiscardButton()} {renderProjectPanel()} {props.breadCrumbs} {hasArticleDraft && ( <View style={styles.content}> <View style={styles.formHeader}> <VisibilityControl style={styles.visibilitySelector} visibility={articleDraftData.visibility} onSubmit={(visibility: Visibility) => updateDraft({ visibility, }) } uiTheme={theme.uiTheme} getOptions={(q: string) => getApi().articles.getDraftVisibilityOptions(articleDraft.id, q)} /> {articleDraft?.id && !props.isNew && ( <Badge text="unpublished changes" /> )} </View> <SummaryDescriptionForm analyticsId={ANALYTICS_ARTICLE_CREATE_PAGE} testID="createIssueSummary" summary={articleDraftData.summary} description={articleDraftData.content} editable={!!articleDraft} onSummaryChange={(summary: string) => updateDraft({ summary, }) } onDescriptionChange={(content: string) => updateDraft({ content, }) } summaryPlaceholder={i18n('Title')} descriptionPlaceholder={i18n('Article content')} /> </View> )} {hasArticleDraft && ( <> <Separator fitWindow indent /> <View style={styles.attachments}> <AttachmentAddPanel isDisabled={isProcessing} showAddAttachDialog={() => dispatch(articleCreateActions.showAddAttachDialog()) } /> <AttachmentsRow attachments={articleDraft.attachments} attachingImage={attachingImage} canRemoveAttachment={true} onRemoveImage={(attachment: Attachment) => dispatch( articleCreateActions.deleteDraftAttachment(attachment.id), ) } uiTheme={theme.uiTheme} /> </View> <View style={styles.attachments} /> </> )} </ScrollView> {isAttachFileDialogVisible && renderAttachFileDialog()} </KeyboardAvoidingView> ); }; export default React.memo<ArticleCreateState>(ArticleCreate);