src/views/issues/issues.tsx (661 lines of code) (raw):

import { Dimensions, View, Text, FlatList, TouchableOpacity, Animated, } from 'react-native'; import {RefreshControl} from 'components/haptick/refresh-control'; import React, {Component} from 'react'; import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import * as issueActions from './issues-actions'; import * as actions from './issues-reducers'; import ErrorMessage from 'components/error-message/error-message'; import Issue from 'views/issue/issue'; import IssuePermissions from 'components/issue-permissions/issue-permissions'; import IssueRow, {IssueRowCompact} from './issues__row'; import IssuesCount from './issues__count'; import IssuesFilters from 'views/issues/issues__filters'; import IssuesListSettings from './issues__settings'; import log from 'components/log/log'; import NothingSelectedIconWithText from 'components/icon/nothing-selected-icon-with-text'; import QueryAssistPanel from 'components/query-assist/query-assist-panel'; import QueryPreview from 'components/query-assist/query-preview'; import Router from 'components/router/router'; import usage from 'components/usage/usage'; import {addListenerGoOnline} from 'components/network/network-events'; import {ANALYTICS_ISSUES_PAGE} from 'components/analytics/analytics-ids'; import {createAnimatedRotateStyle} from 'views/issues/issues-helper'; import {DEFAULT_THEME} from 'components/theme/theme'; import {ERROR_MESSAGE_DATA} from 'components/error/error-message-data'; import {HIT_SLOP, HIT_SLOP2} from 'components/common-styles'; import {getIssueFromCache} from './index'; import {hasType} from 'components/api/api__resource-types'; import {i18n} from 'components/i18n/i18n'; import {IconAdd, IconAngleDown, IconMoreOptions} from 'components/icon/icon'; import { ICON_PICTOGRAM_DEFAULT_SIZE, IconNothingFound, } from 'components/icon/icon-pictogram'; import {initialState} from './issues-reducers'; import {isReactElement} from 'util/util'; import {isSplitView} from 'components/responsive/responsive-helper'; import { FilterFieldSetting, issuesSearchSettingMode, issuesViewSettingMode, } from 'views/issues/index'; import {logEvent} from 'components/log/log-helper'; import {notify} from 'components/notification/notification'; import {requestController} from 'components/api/api__request-controller'; import {routeMap} from 'app-routes'; import {SkeletonIssues, SkeletonIssuesS} from 'components/skeleton/skeleton'; import {Select, SelectModal} from 'components/select/select'; import { SectionedSelectWithItemActions, SectionedSelectWithItemActionsModal, } from 'components/select/select-sectioned-with-item-and-star'; import {ThemeContext} from 'components/theme/theme-context'; import {UNIT} from 'components/variables'; import styles from './issues.styles'; import type Api from 'components/api/api'; import type Auth from 'components/auth/oauth2'; import type {AnyIssue, IssueOnListExtended} from 'types/Issue'; import type {AppState} from 'reducers'; import type {ErrorMessageProps} from 'components/error-message/error-message'; import type {EventSubscription} from 'react-native/Libraries/vendor/emitter/EventEmitter'; import type {Folder, User} from 'types/User'; import type {Theme, UIThemeColors} from 'types/Theme'; import type {IssuesState} from './issues-reducers'; import type {NetInfoState} from '@react-native-community/netinfo'; import type {ReduxAction, ReduxThunkDispatch} from 'types/Redux'; type ReduxExtraActions = {[fnName: string]: ReduxAction<unknown>}; type IssuesActions = typeof issueActions; export type IssuesProps = IssuesState & IssuesActions & ReduxExtraActions & { auth: Auth; api: Api; issueId?: string; searchQuery?: string; networkState: NetInfoState, isInProgress: boolean, user: User, onFilterPress: (filterField: FilterFieldSetting) => any, issuePermissions: IssuePermissions, onQueryUpdate: (query: string) => ReduxThunkDispatch onOpenContextSelect: () => ReduxThunkDispatch updateSearchContextPinned: ReduxThunkDispatch; setIssuesCount: (count: number | null) => ReduxThunkDispatch; updateIssue: (issueId: string) => ReduxThunkDispatch; }; interface State { isEditQuery: boolean; clearSearchQuery: boolean; focusedIssue: AnyIssue | null; isSplitView: boolean; settingsVisible: boolean; } export class Issues<P extends IssuesProps> extends Component<P, State> { unsubscribeOnDispatch: ((...args: any[]) => any) | undefined; unsubscribeOnDimensionsChange: EventSubscription | undefined; theme: Theme = {uiTheme: DEFAULT_THEME, mode: DEFAULT_THEME.mode, setMode: () => {}}; goOnlineSubscription: EventSubscription | undefined; constructor(props: P) { super(props); this.state = { isEditQuery: false, clearSearchQuery: false, focusedIssue: null, isSplitView: false, settingsVisible: false, }; this.props.setIssuesMode(); this.props.setIssuesFromCache(); usage.trackScreenView(ANALYTICS_ISSUES_PAGE); } get searchQuery() { return this.props.query; } onDimensionsChange = (): void => { const isSplit: boolean = isSplitView(); this.setState({ isSplitView: isSplit, focusedIssue: isSplit ? this.state.focusedIssue : null, }); }; refresh() { this.props.initializeIssuesList(this.props.searchQuery); } async componentDidMount() { this.unsubscribeOnDimensionsChange = Dimensions.addEventListener( 'change', this.onDimensionsChange, ); this.onDimensionsChange(); this.refresh(); this.unsubscribeOnDispatch = Router.setOnDispatchCallback( ( routeName: string, prevRouteName: string, options: Record<string, any>, ) => { if ( (prevRouteName === routeMap.Issues || prevRouteName === routeMap.Tickets) && (routeName !== routeMap.Issues || prevRouteName !== routeMap.Tickets) ) { requestController.cancelIssuesRequests(); } if (prevRouteName === routeMap.HelpDeskFeedback && routeName === routeMap.Tickets) { this.refresh(); } if ( (routeName === routeMap.Issues || routeName === routeMap.Tickets) && prevRouteName === routeMap.Issue && options?.issueId ) { this.props.updateIssue(options.issueId); if (this.props.issuesCount === null) { this.props.refreshIssuesCount(); } } }, ); this.initFocusedIssue(this.props.issueId); this.goOnlineSubscription = addListenerGoOnline(() => { this.refresh(); }); } initFocusedIssue(issueId?: string) { if (issueId) { const targetIssue: IssueOnListExtended | null = getIssueFromCache(issueId); this.updateFocusedIssue(targetIssue || {id: issueId} as IssueOnListExtended); } } UNSAFE_componentWillReceiveProps(nextProps: IssuesProps) { if(nextProps.issueId !== this.props.issueId) { this.initFocusedIssue(nextProps.issueId); } } componentWillUnmount() { this.unsubscribeOnDimensionsChange?.remove?.(); this.unsubscribeOnDispatch?.(); this.goOnlineSubscription?.remove?.(); } shouldComponentUpdate(nextProps: IssuesProps, nextState: State): boolean { if ( Object.keys(initialState).some((stateKey: string) => (this.props)[stateKey] !== nextProps[stateKey]) ) { return true; } return this.state !== nextState; } goToIssue(issue: IssueOnListExtended) { log.info(`Issues: Opening issue from the Issues`); if (!issue.id) { log.warn('Issues: Attempt to open bad issue'); notify('Attempt to open issue without ID', 7000); return; } Router.Issue({ issuePlaceholder: issue, issueId: issue.id, }); } isMatchesQuery = async (issueIdReadable: string) => { return await this.props.isIssueMatchesQuery(issueIdReadable); }; renderSettingsButton() { const animatedStyle = ( !this.props.isInProgress && !this.isFilterSearchMode() && this.props.searchQuery ? createAnimatedRotateStyle() : null ); return ( <TouchableOpacity style={[styles.listActionsItem, styles.listActionsItemMore]} disabled={this.props.isInProgress} testID="test:id/issuesSettingsButton" accessibilityLabel="issuesSettingsButton" onPress={() => { this.toggleSettingsVisibility(true); }} hitSlop={HIT_SLOP} > <Animated.View style={animatedStyle}> <IconMoreOptions style={styles.iconSettings} color={this.props.isInProgress ? this.getThemeColors().$disabled : this.getThemeColors().$link} /> </Animated.View> </TouchableOpacity> ); } canCreateIssue() { if (this.props.helpDeskMode) { return this.props.helpDeskProjects.length > 0; } return this.props?.issuePermissions?.canCreateProject?.(); } renderCreateIssueButton = () => { return this.canCreateIssue() ? ( <TouchableOpacity testID="test:id/create-issue-button" accessibilityLabel="create-issue-button" accessible={true} style={styles.listActionsItem} onPress={() => { if (this.props.helpDeskMode) { this.props.onOpenHelpDeskProjectsSelect(); } else { Router.CreateIssue({isMatchesQuery: this.isMatchesQuery, onHide: () => Router.Issues()}); } }} disabled={this.props.isInProgress} hitSlop={HIT_SLOP2} > <IconAdd color={ this.props.isInProgress ? this.getThemeColors().$disabled : this.getThemeColors().$link } /> </TouchableOpacity> ) : null; }; _renderRow = ({item}: { item: IssueOnListExtended }) => { const {settings} = this.props; const {focusedIssue, isSplitView} = this.state; if (isReactElement(item)) { return item; } const IssueRowComponent = settings.view.mode === issuesViewSettingMode.S ? IssueRowCompact : IssueRow; const contextIsProject: any = hasType.project(this.props.searchContext); const filterFieldProject: FilterFieldSetting | undefined = settings.search.filters?.project; const selectedProjects: string[] | undefined = filterFieldProject?.selectedValues; const hideId: boolean = ( contextIsProject && selectedProjects?.length === 0 || !contextIsProject && selectedProjects?.length === 1 || ( contextIsProject && selectedProjects?.length === 1 && filterFieldProject && this.props.searchContext.id === filterFieldProject.filterField?.[0]?.customField?.id ) ); return ( <View style={[ focusedIssue?.id === item.id || item.idReadable && focusedIssue?.id === item.idReadable ? styles.splitViewMainFocused : null, ]} > <IssueRowComponent absDate={!!this.props.user?.profiles?.appearance?.useAbsoluteDates} helpdeskMode={this.props.helpDeskMode} hideId={hideId} settings={settings} issue={item} onClick={issue => { if (isSplitView) { this.updateFocusedIssue(issue); } else { this.goToIssue(issue); } }} onTagPress={(searchQuery: string) => Router.Issues({ searchQuery, }) } /> </View> ); }; getKey = (item: Record<string, any>) => { return `${isReactElement(item) ? item.key : item.id}`; }; _renderRefreshControl() { return ( <RefreshControl refreshing={false} onRefresh={this.props.refreshIssues} tintColor={this.theme.uiTheme.colors.$link} testID="refresh-control" accessibilityLabel="refresh-control" accessible={true} /> ); } _renderSeparator = (item: unknown) => { if (isReactElement((item as any).leadingItem)) { return null; } return <View style={styles.separator}/>; }; onEndReached = () => { this.props.loadMoreIssues(); }; getThemeColors(): UIThemeColors { return this.theme.uiTheme.colors; } getSearchContext(): Folder { return this.props.searchContext; } isReporter(): boolean { return !!this.props?.user?.profiles?.helpdesk?.isReporter; } renderContextButton() { const { isRefreshing, isSearchContextPinned, networkState, } = this.props; const searchContext = this.getSearchContext(); const isDisabled: boolean = isRefreshing || !networkState?.isConnected; const themeColors: UIThemeColors = this.getThemeColors(); return ( <View key="issueListContext" accessible={true} testID="test:id/issue-list-context" style={[styles.searchContext, isSearchContextPinned ? styles.searchContextPinned : null]} > <TouchableOpacity disabled={isDisabled} onPress={this.props.onOpenContextSelect} style={styles.searchContextButton}> <Text numberOfLines={1} style={styles.contextButtonText}> {searchContext.name.replace(' ', '\xa0')} </Text> <IconAngleDown style={styles.contextButtonIcon} color={isDisabled ? themeColors.$disabled : themeColors.$text} size={19} /> </TouchableOpacity> </View> ); } renderContextSelect() { const {selectProps} = this.props; const {onSelect, isSectioned, ...restProps} = selectProps!; const SelectComponent: React.ElementType = ( isSplitView() ? isSectioned ? SectionedSelectWithItemActionsModal : SelectModal : isSectioned ? SectionedSelectWithItemActions : Select ); return ( <SelectComponent onSelect={async (selectedContext: Folder) => { this.updateFocusedIssue(null); onSelect?.(selectedContext); }} {...restProps} getTitle={(item: Folder) => item.name + (item.shortName ? ` (${item.shortName})` : '')} /> ); } onScroll: (nativeEvent: any) => void = (nativeEvent: Record<string, any>) => { const newY = nativeEvent.contentOffset.y; const isPinned: boolean = newY >= UNIT; if (this.props.isSearchContextPinned !== isPinned) { this.props.updateSearchContextPinned(isPinned); } }; setEditQueryMode(isEditQuery: boolean) { this.setState({ isEditQuery, }); } clearSearchQuery(clearSearchQuery: boolean) { this.setState({ clearSearchQuery, }); } updateFocusedIssue(focusedIssue: AnyIssue | null) { this.setState({focusedIssue}); } getAnalyticId() { return ANALYTICS_ISSUES_PAGE; } onSearchQueryPanelFocus: (clearSearchQuery?: boolean) => void = ( clearSearchQuery: boolean = false, ) => { logEvent({ message: 'Focus search panel', analyticsId: this.getAnalyticId(), }); this.setEditQueryMode(true); this.clearSearchQuery(clearSearchQuery); }; onQueryUpdate = (query: string) => { logEvent({ message: 'Apply search', analyticsId: this.getAnalyticId(), }); this.setEditQueryMode(false); this.props.setIssuesCount(null); this.props.onQueryUpdate(query); }; renderSearchQueryAssist = () => { const {suggestIssuesQuery, queryAssistSuggestions} = this.props; const _query = this.state.clearSearchQuery ? '' : this.searchQuery; return ( <QueryAssistPanel key="QueryAssistPanel" queryAssistSuggestions={queryAssistSuggestions} query={_query} suggestIssuesQuery={suggestIssuesQuery} onQueryUpdate={(q: string) => { this.onQueryUpdate(q); }} onClose={(q: string) => { if (this.state.clearSearchQuery) { logEvent({ message: 'Clear search', analyticsId: this.getAnalyticId(), }); this.onQueryUpdate(q); } else { this.setEditQueryMode(false); } }} clearButtonMode="always" /> ); }; isFilterSearchMode() { return this.props.settings.search.mode === issuesSearchSettingMode.filter || this.props.helpDeskMode; } renderSearchPanel() { return ( <> <View style={styles.searchPanel}> {this.state.isEditQuery ? this.renderSearchQueryAssist() : this.renderSearchQueryPreview()} </View> {this.isFilterSearchMode() && <IssuesFilters/>} </> ); } hasIssues: () => boolean = (): boolean => this.props.issues?.length > 0; toggleSettingsVisibility = (settingsVisible: boolean) => { this.setState({settingsVisible}); }; renderToolbar() { return ( <View style={styles.toolbar}> {this.hasIssues() ? <IssuesCount issuesCount={this.props.issuesCount} isHelpdesk={this.props.helpDeskMode} /> : <View />} </View> ); } renderSearchQueryPreview() { const {isRefreshing} = this.props; const isFilterSearchMode: boolean = this.isFilterSearchMode(); return ( <QueryPreview style={styles.searchQueryPreview} editable={!isRefreshing} placeholder={isFilterSearchMode ? i18n('Find issues that contain key words') : undefined} query={this.searchQuery} onSubmit={isFilterSearchMode ? this.props.onQueryUpdate : undefined} onFocus={!isFilterSearchMode ? this.onSearchQueryPanelFocus : undefined} /> ); } renderSkeleton() { return this.props.settings.view.mode === issuesViewSettingMode.S ? <SkeletonIssuesS/> : <SkeletonIssues/>; } renderIssuesFooter = () => { const {isLoadingMore} = this.props; return isLoadingMore ? this.renderSkeleton() : this.renderError(); }; renderIssueList() { const {issues, isRefreshing} = this.props; const contextButton = this.renderContextButton(); const searchPanel: React.ReactNode = <> {this.renderSearchPanel()} {this.renderToolbar()} </>; if (isRefreshing && (!issues || issues.length === 0)) { return ( <View style={styles.list}> {contextButton} {searchPanel} {this.renderSkeleton()} </View> ); } const listData = [ contextButton, searchPanel, ].filter(Boolean).concat((issues || []) as any); return ( <FlatList style={styles.list} testID="issue-list" stickyHeaderIndices={[0]} removeClippedSubviews={false} data={listData} keyExtractor={this.getKey} renderItem={this._renderRow as any} ItemSeparatorComponent={this._renderSeparator} ListEmptyComponent={() => { return <Text>{i18n('No issues found')}</Text>; }} ListFooterComponent={this.renderIssuesFooter as any} refreshControl={this._renderRefreshControl()} onScroll={params => this.onScroll(params.nativeEvent)} onEndReached={this.onEndReached} onEndReachedThreshold={0.1} /> ); } renderError(): React.ReactNode { const {isRefreshing, loadingError, isInitialized} = this.props; if (isRefreshing || !isInitialized) { return null; } const props: ErrorMessageProps = Object.assign( {}, loadingError ? { error: loadingError, } : !this.hasIssues() ? { errorMessageData: { ...ERROR_MESSAGE_DATA.NO_ISSUES_FOUND, icon: () => ( <IconNothingFound size={ICON_PICTOGRAM_DEFAULT_SIZE} style={styles.noIssuesFoundIcon} /> ), }, } : null, ) as ErrorMessageProps; if (Object.keys(props).length > 0) { return <ErrorMessage testID="issuesLoadingError" {...props} />; } return null; } renderIssues = () => { const {isIssuesContextOpen} = this.props; return ( <View style={styles.listContainer} testID="test:id/issueListPhone"> {isIssuesContextOpen && this.renderContextSelect()} {this.renderIssueList()} <View style={styles.listActions}> {this.renderCreateIssueButton()} {this.renderSettingsButton()} </View> </View> ); }; renderFocusedIssue = () => { const {focusedIssue} = this.state; if (!focusedIssue || !this.hasIssues()) { return ( <NothingSelectedIconWithText text={i18n('Select an issue from the list')} /> ); } return ( <View style={styles.splitViewMain}> <Issue issuePlaceholder={focusedIssue} issueId={focusedIssue.id} onCommandApply={() => { this.refresh(); }} /> </View> ); }; renderSplitView = () => { return ( <> <View style={styles.splitViewSide}>{this.renderIssues()}</View> <View style={styles.splitViewMain}>{this.renderFocusedIssue()}</View> </> ); }; renderSettings() { return this.state.settingsVisible ? ( <IssuesListSettings onQueryUpdate={this.onQueryUpdate} toggleVisibility={this.toggleSettingsVisibility} onChange={() => { this.refresh(); }} /> ) : null; } render() { return ( <ThemeContext.Consumer> {(theme: Theme) => { this.theme = theme; return ( <View style={[ styles.listContainer, this.state.isSplitView ? styles.splitViewContainer : null, ]} > {this.state.isSplitView && this.renderSplitView()} {!this.state.isSplitView && this.renderIssues()} {this.renderSettings()} </View> ); }} </ThemeContext.Consumer> ); } } export function doConnectComponent(ReactComponent: React.ComponentType<any>, extraActions?: ReduxExtraActions) { return connect( ( state: AppState, ownProps: { issueId?: string; searchQuery?: string; } ) => { return { ...state.issueList, ...ownProps, ...state.app, }; }, (dispatch: ReduxThunkDispatch) => { return { ...bindActionCreators(issueActions, dispatch), onQueryUpdate: (query: string) => dispatch(issueActions.onQueryUpdate(query)), onOpenContextSelect: () => dispatch(issueActions.openContextSelect()), onOpenHelpDeskProjectsSelect: () => dispatch(issueActions.onOpenHelpDeskProjectsSelect()), updateSearchContextPinned: (isSearchScrolledUp: boolean) => dispatch( actions.IS_SEARCH_CONTEXT_PINNED(isSearchScrolledUp) ), setIssuesCount: (count: number | null) => dispatch(actions.SET_ISSUES_COUNT(count)), updateIssue: (issueId: string) => dispatch(issueActions.updateIssue(issueId)), ...(extraActions ? bindActionCreators<ReduxExtraActions, ReduxExtraActions>(extraActions, dispatch) : {}), }; } )(ReactComponent); } export default doConnectComponent(Issues);