import { LoadingSpinner } from '@sourcegraph/react-loading-spinner';
import { uniq, upperFirst } from 'lodash';
import * as React from 'react';
import { combineLatest, merge, of, Subject, Subscription } from 'rxjs';
import { catchError, debounceTime, delay, distinctUntilChanged, filter, map, publishReplay, refCount, skip, startWith, switchMap, takeUntil, tap, } from 'rxjs/operators';
import { asError, isErrorLike } from '../util/errors';
import { pluralize } from '../util/strings';
import { Form } from './Form';
import { RadioButtons } from './RadioButtons';
/** Checks if the passed value satisfies the GraphQL Node interface */
const hasID = (obj) => obj && typeof obj.id === 'string';
class FilteredConnectionFilterControl extends React.PureComponent {
    constructor() {
        super(...arguments);
        this.onChange = e => {
            const id = e.currentTarget.value;
            const filter = this.props.filters.find(f => f.id === id);
            this.props.onDidSelectFilter(filter);
        };
    }
    render() {
        return React.createElement(RadioButtons, { nodes: this.props.filters, selected: this.props.value, onChange: this.onChange });
    }
}
class ConnectionNodes extends React.PureComponent {
    constructor() {
        super(...arguments);
        this.onClickShowMore = () => this.props.onShowMore();
    }
    componentDidMount() {
        if (this.props.location.hash) {
            this.scrollToHash(this.props.location.hash);
        }
    }
    componentDidUpdate(prevProps) {
        if (prevProps.connection !== this.props.connection || prevProps.location.hash !== this.props.location.hash) {
            // Try scrolling when either the content or the hash changed.
            this.scrollToHash(this.props.location.hash);
        }
    }
    render() {
        const NodeComponent = this.props.nodeComponent;
        const ListComponent = this.props.listComponent || 'ul';
        const HeadComponent = this.props.headComponent;
        const FootComponent = this.props.footComponent;
        const hasNextPage = this.props.connection &&
            ((this.props.connection.pageInfo && this.props.connection.pageInfo.hasNextPage) ||
                (typeof this.props.connection.totalCount === 'number' &&
                    this.props.connection.nodes.length < this.props.connection.totalCount));
        let totalCount = null;
        if (this.props.connection) {
            if (typeof this.props.connection.totalCount === 'number') {
                totalCount = this.props.connection.totalCount;
            }
            else if (
            // TODO(sqs): this line below is wrong because this.props.first might've just been changed and
            // this.props.connection.nodes is still the data fetched from before this.props.first was changed.
            // this causes the UI to incorrectly show "N items total" even when the count is indeterminate right
            // after the user clicks "Show more" but before the new data is loaded.
            this.props.connection.nodes.length < this.props.first ||
                (this.props.connection.nodes.length === this.props.first &&
                    this.props.connection.pageInfo &&
                    typeof this.props.connection.pageInfo.hasNextPage === 'boolean' &&
                    !this.props.connection.pageInfo.hasNextPage)) {
                totalCount = this.props.connection.nodes.length;
            }
        }
        let summary;
        if (!this.props.loading &&
            this.props.connection &&
            (!this.props.noSummaryIfAllNodesVisible || this.props.connection.nodes.length === 0 || hasNextPage)) {
            if (totalCount !== null && totalCount > 0) {
                summary = (React.createElement("p", { className: "filtered-connection__summary" },
                    React.createElement("small", null,
                        React.createElement("span", null,
                            totalCount,
                            " ",
                            pluralize(this.props.noun, totalCount, this.props.pluralNoun),
                            ' ',
                            this.props.connectionQuery ? (React.createElement("span", null,
                                ' ',
                                "matching ",
                                React.createElement("strong", null, this.props.connectionQuery))) : ('total')),
                        ' ',
                        this.props.connection.nodes.length < totalCount &&
                            `(showing first ${this.props.connection.nodes.length})`)));
            }
            else if (this.props.connection.pageInfo && this.props.connection.pageInfo.hasNextPage) {
                // No total count to show, but it will show a 'Show more' button.
            }
            else if (totalCount === 0) {
                summary = this.props.emptyElement || (React.createElement("p", { className: "filtered-connection__summary" },
                    React.createElement("small", null,
                        "No ",
                        this.props.pluralNoun,
                        ' ',
                        this.props.connectionQuery && (React.createElement("span", null,
                            "matching ",
                            React.createElement("strong", null, this.props.connectionQuery))))));
            }
        }
        const nodes = this.props.connection.nodes.map((node, i) => (React.createElement(NodeComponent, Object.assign({ key: hasID(node) ? node.id : i, node: node }, this.props.nodeComponentProps))));
        return (React.createElement(React.Fragment, null,
            this.props.connectionQuery && summary,
            this.props.connection &&
                this.props.connection.nodes.length > 0 && (React.createElement(ListComponent, { className: `filtered-connection__nodes ${this.props.listClassName || ''}` },
                HeadComponent && React.createElement(HeadComponent, { nodes: this.props.connection.nodes }),
                ListComponent === 'table' ? React.createElement("tbody", null, nodes) : nodes,
                FootComponent && React.createElement(FootComponent, { nodes: this.props.connection.nodes }))),
            !this.props.connectionQuery && summary,
            !this.props.loading &&
                !this.props.noShowMore &&
                this.props.connection &&
                hasNextPage && (React.createElement("button", { className: `btn btn-secondary btn-sm filtered-connection__show-more ${this.props
                    .showMoreClassName || ''}`, onClick: this.onClickShowMore }, "Show more"))));
    }
    /** Scroll to the anchor in the page identified by the location fragment (e.g., #my-section). */
    scrollToHash(hash) {
        if (!hash) {
            return;
        }
        // This does not cause a page navigation (because window.location.hash === hash already), but it does cause
        // the page to scroll to the hash. This is simpler than using scrollTo, scrollIntoView, etc. Also assigning
        // window.location.hash does not trigger a navigation when `window.location.hash === hash`, so we can't
        // just use that.
        //
        // Finally, ensure that hash begins with a "#" so that this can't be used to redirect to arbitrary URLs (as
        // an open redirect vulnerability). This should always be true, but just be safe and document this
        // assertion in code.
        if (hash.startsWith('#')) {
            window.location.href = hash;
        }
    }
}
/** The URL query parameter where the search query for FilteredConnection is stored. */
const QUERY_KEY = 'query';
/**
 * Displays a collection of items with filtering and pagination. It is called
 * "connection" because it is intended for use with GraphQL, which calls it that
 * (see http://graphql.org/learn/pagination/).
 *
 * @template C The GraphQL connection type, such as GQL.IRepositoryConnection.
 * @template N The node type of the GraphQL connection, such as GQL.IRepository (if C is GQL.IRepositoryConnection)
 * @template NP Props passed to `nodeComponent` in addition to `{ node: N }`
 */
export class FilteredConnection extends React.PureComponent {
    constructor(props) {
        super(props);
        this.queryInputChanges = new Subject();
        this.activeFilterChanges = new Subject();
        this.showMoreClicks = new Subject();
        this.componentUpdates = new Subject();
        this.subscriptions = new Subscription();
        this.filterRef = null;
        this.setFilterRef = (e) => {
            this.filterRef = e;
            if (e && this.props.autoFocus) {
                // TODO(sqs): The 30 msec delay is needed, or else the input is not
                // reliably focused. Find out why.
                setTimeout(() => e.focus(), 30);
            }
        };
        this.focusFilter = () => {
            if (this.filterRef) {
                this.filterRef.focus();
            }
        };
        this.onSubmit = e => {
            // Do nothing. The <input onChange> handler will pick up any changes shortly.
            e.preventDefault();
        };
        this.onChange = e => {
            this.queryInputChanges.next(e.currentTarget.value);
        };
        this.onDidSelectFilter = (filter) => this.activeFilterChanges.next(filter);
        this.onClickShowMore = () => {
            this.showMoreClicks.next();
        };
        const q = new URLSearchParams(this.props.location.search);
        this.state = {
            loading: true,
            query: (!this.props.hideSearch && q.get(QUERY_KEY)) || '',
            activeFilter: getFilterFromURL(q, this.props.filters),
            first: parseQueryInt(q, 'first') || this.props.defaultFirst,
        };
    }
    componentDidMount() {
        const activeFilterChanges = this.activeFilterChanges.pipe(startWith(this.state.activeFilter), distinctUntilChanged());
        const queryChanges = this.queryInputChanges.pipe(distinctUntilChanged(), tap(query => !this.props.hideSearch && this.setState({ query })), debounceTime(200), startWith(this.state.query));
        const refreshRequests = new Subject();
        this.subscriptions.add(activeFilterChanges
            .pipe(tap(filter => {
            if (this.props.onFilterSelect) {
                this.props.onFilterSelect(filter ? filter.id : undefined);
            }
        }))
            .subscribe());
        this.subscriptions.add(
        // Use this.activeFilterChanges not activeFilterChanges so that it doesn't trigger on the initial mount
        // (it doesn't need to).
        this.activeFilterChanges.subscribe(filter => this.setState({ activeFilter: filter })));
        // Track the last query and filter we used. We only want to show the loader if these change,
        // not when a refresh is requested for the same query/filter (or else there would be jitter).
        let lastQuery;
        let lastFilter;
        this.subscriptions.add(combineLatest(queryChanges, activeFilterChanges, refreshRequests.pipe(startWith(undefined)))
            .pipe(tap(([query, filter]) => {
            if (this.props.shouldUpdateURLQuery) {
                this.props.history.replace({
                    search: this.urlQuery({ query, filter }),
                    hash: this.props.location.hash,
                });
            }
        }), switchMap(([query, filter]) => {
            const result = this.props
                .queryConnection(Object.assign({ first: this.state.first, query }, (filter ? filter.args : {})))
                .pipe(catchError(error => [asError(error)]), map(c => ({
                connectionOrError: c,
                connectionQuery: query,
                loading: false,
            })), publishReplay(), refCount());
            const showLoading = query !== lastQuery || filter !== lastFilter;
            lastQuery = query;
            lastFilter = filter;
            return showLoading
                ? merge(result, of({ connectionOrError: undefined, loading: true }).pipe(delay(250), takeUntil(result)))
                : result;
        }), tap(({ connectionOrError }) => {
            if (this.props.onUpdate) {
                this.props.onUpdate(connectionOrError);
            }
        }))
            .subscribe(stateUpdate => this.setState(stateUpdate), err => console.error(err)));
        this.subscriptions.add(this.showMoreClicks
            .pipe(map(() => this.state.first * 2))
            .subscribe(first => this.setState({ first, loading: true }, () => refreshRequests.next())));
        if (this.props.updates) {
            this.subscriptions.add(this.props.updates.subscribe(c => {
                this.setState({ loading: true }, () => refreshRequests.next());
            }));
        }
        this.subscriptions.add(this.componentUpdates
            .pipe(distinctUntilChanged((a, b) => a.updateOnChange === b.updateOnChange), filter(({ updateOnChange }) => updateOnChange !== undefined))
            .subscribe(() => {
            this.setState({ loading: true, connectionOrError: undefined }, () => refreshRequests.next());
        }));
        // Reload collection when the query callback changes.
        this.subscriptions.add(this.componentUpdates
            .pipe(map(({ queryConnection }) => queryConnection), distinctUntilChanged(), skip(1), // prevent from triggering on initial mount
        tap(() => this.focusFilter()))
            .subscribe(() => this.setState({ loading: true, connectionOrError: undefined }, () => refreshRequests.next())));
        this.componentUpdates.next(this.props);
    }
    urlQuery(arg) {
        if (!arg.first) {
            arg.first = this.state.first;
        }
        if (!arg.query) {
            arg.query = this.state.query;
        }
        if (!arg.filter) {
            arg.filter = this.state.activeFilter;
        }
        const q = new URLSearchParams();
        if (arg.query) {
            q.set(QUERY_KEY, arg.query);
        }
        if (arg.first !== this.props.defaultFirst) {
            q.set('first', String(arg.first));
        }
        if (arg.filter && this.props.filters && arg.filter !== this.props.filters[0]) {
            q.set('filter', arg.filter.id);
        }
        return q.toString();
    }
    componentWillReceiveProps(nextProps) {
        this.componentUpdates.next(nextProps);
    }
    componentWillUnmount() {
        this.subscriptions.unsubscribe();
    }
    render() {
        const errors = [];
        if (isErrorLike(this.state.connectionOrError)) {
            errors.push(...uniq(this.state.connectionOrError.message.split('\n')));
        }
        if (this.state.connectionOrError &&
            !isErrorLike(this.state.connectionOrError) &&
            this.state.connectionOrError.error) {
            errors.push(this.state.connectionOrError.error);
        }
        const compactnessClass = `filtered-connection--${this.props.compact ? 'compact' : 'noncompact'}`;
        return (React.createElement("div", { className: `filtered-connection ${compactnessClass} ${this.props.className || ''}` },
            (!this.props.hideSearch || this.props.filters) && (React.createElement(Form, { className: "filtered-connection__form", onSubmit: this.onSubmit },
                !this.props.hideSearch && (React.createElement("input", { className: "form-control filtered-connection__filter", type: "search", placeholder: `Search ${this.props.pluralNoun}...`, name: "query", value: this.state.query, onChange: this.onChange, autoFocus: this.props.autoFocus, autoComplete: "off", autoCorrect: "off", autoCapitalize: "off", ref: this.setFilterRef, spellCheck: false })),
                this.props.filters &&
                    this.state.activeFilter && (React.createElement(FilteredConnectionFilterControl, { filters: this.props.filters, onDidSelectFilter: this.onDidSelectFilter, value: this.state.activeFilter.id })))),
            errors.length > 0 && (React.createElement("div", { className: "alert alert-danger filtered-connection__error" }, errors.map((m, i) => (React.createElement(React.Fragment, { key: i },
                upperFirst(m),
                React.createElement("br", null)))))),
            this.state.connectionOrError &&
                !isErrorLike(this.state.connectionOrError) && (React.createElement(ConnectionNodes, { connection: this.state.connectionOrError, loading: this.state.loading, connectionQuery: this.state.connectionQuery, first: this.state.first, query: this.state.query, noun: this.props.noun, pluralNoun: this.props.pluralNoun, listComponent: this.props.listComponent, listClassName: this.props.listClassName, headComponent: this.props.headComponent, footComponent: this.props.footComponent, showMoreClassName: this.props.showMoreClassName, nodeComponent: this.props.nodeComponent, nodeComponentProps: this.props.nodeComponentProps, noShowMore: this.props.noShowMore, noSummaryIfAllNodesVisible: this.props.noSummaryIfAllNodesVisible, onShowMore: this.onClickShowMore, location: this.props.location, emptyElement: this.props.emptyElement })),
            this.state.loading && (React.createElement("span", { className: "filtered-connection__loader" },
                React.createElement(LoadingSpinner, { className: "icon-inline" })))));
    }
}
FilteredConnection.defaultProps = {
    defaultFirst: 20,
    shouldUpdateURLQuery: true,
};
function parseQueryInt(q, name) {
    const s = q.get(name);
    if (s === null) {
        return null;
    }
    const n = parseInt(s, 10);
    if (n > 0) {
        return n;
    }
    return null;
}
function getFilterFromURL(q, filters) {
    if (filters === undefined || filters.length === 0) {
        return undefined;
    }
    const id = q.get('filter');
    if (id !== null) {
        const filter = filters.find(f => f.id === id);
        if (filter) {
            return filter;
        }
    }
    return filters[0]; // default
}
//# sourceMappingURL=FilteredConnection.js.map