superdesk/superdesk-client-core

View on GitHub
scripts/apps/vocabularies/components/VocabularyItemsViewEdit.tsx

Summary

Maintainability
F
3 days
Test Coverage
/* eslint-disable react/no-multi-comp */

import React from 'react';
import ReactPaginate from 'react-paginate';
import ObjectEditor from './ObjectEditor';
import {once, debounce} from 'lodash';
import {gettext} from 'core/utils';
import {ISortOption, IVocabularyItem} from 'superdesk-api';
import {assertNever} from 'core/helpers/typescript-helpers';
import {Dropdown} from 'core/ui/components/Dropdown/Dropdown';
import {Menu} from 'core/ui/components/Dropdown/Menu';
import {dataApi} from 'core/helpers/CrudManager';
import {ILanguage} from 'superdesk-interfaces/Language';
import {ManageVocabularyItemTranslations} from '../ManageVocabularyItemTranslations';

interface ISchemaField {
    key: string;
    label?: string;
    type?: 'object' | string;
    required?: boolean;
}

interface IVocabularyItemWithId extends IVocabularyItem {
    id: string;
}

const pageSize = 50;

function getPageCount(items: Array<IVocabularyItemWithId>) {
    return Math.ceil(items.length / pageSize);
}

function sortItems(items: Array<IVocabularyItemWithId>, sort: ISortOption): Array<IVocabularyItemWithId> {
    return [...items].sort((a, b) => {
        if (sort.direction === 'ascending') {
            if (a[sort.field] < b[sort.field]) {
                return -1;
            } else if (a[sort.field] > b[sort.field]) {
                return 1;
            } else {
                return 0;
            }
        } else if (sort.direction === 'descending') {
            if (a[sort.field] < b[sort.field]) {
                return 1;
            } else if (a[sort.field] > b[sort.field]) {
                return -1;
            } else {
                return 0;
            }
        } else {
            return assertNever(sort.direction);
        }
    });
}

interface IPropsInputField {
    field: ISchemaField;
    item: IVocabularyItemWithId;
    required: boolean;
    update(item: any, key: string, value: any): void;
}

function isItemFieldValid(item: IVocabularyItemWithId, field: ISchemaField): boolean {
    if (!field.required) {
        return true;
    } else if (typeof item[field.key] === 'string') {
        return item[field.key].length > 0;
    } else {
        return item[field.key] != null;
    }
}

function containsInvalidItems(items: Array<IVocabularyItemWithId>, schemaFields: Array<ISchemaField>): boolean {
    return items.some((item) => {
        return schemaFields.some((field) => {
            const valid = isItemFieldValid(item, field);

            return !valid;
        });
    });
}

class InputField extends React.PureComponent<IPropsInputField> {
    render() {
        const {field, item, required} = this.props;
        const value = item[field.key] || '';
        const valueObj = item[field.key] ?? {};
        const disabled = !item.is_active;

        let className = 'sd-line-input sd-line-input--no-margin sd-line-input--no-label sd-line-input--boxed';

        if (required) {
            className += ' sd-line-input--required';
        }
        if (!isItemFieldValid(item, field)) {
            className += ' sd-line-input--invalid';
        }

        switch (field.type) {
        case 'bool':
            return (
                <input
                    type="checkbox"
                    checked={!!value}
                    disabled={disabled}
                    onChange={() => this.props.update(item, field.key, !value)}
                />
            );

        case 'color':
            return (
                <input
                    type="color"
                    value={value}
                    disabled={disabled}
                    onChange={(event) => this.props.update(item, field.key, event.target.value)}
                />
            );

        case 'short':
            return (
                <input
                    type="text"
                    value={value}
                    disabled={disabled}
                    onChange={(event) => {
                        this.props.update(item, field.key, event.target.value);
                    }}
                />
            );

        case 'object': {
            return (
                <ObjectEditor
                    value={valueObj}
                    disabled={disabled}
                    onChange={(_value) => this.props.update(item, field.key, _value)}
                />
            );
        }

        case 'integer':
            return (
                <div className={className}>
                    <input
                        type="number"
                        value={value}
                        disabled={disabled}
                        className={field.key === 'name' ? 'long-name sd-line-input__input' : 'sd-line-input__input'}
                        onChange={(event) => {
                            this.props.update(item, field.key, parseInt(event.target.value, 10));
                        }}
                    />
                </div>
            );

        default:
            return (
                <div className={className}>
                    <input
                        type="text"
                        className={field.key === 'name' ? 'long-name sd-line-input__input' : 'sd-line-input__input'}
                        value={value}
                        disabled={disabled}
                        onChange={(event) => {
                            this.props.update(item, field.key, event.target.value);
                        }}
                        data-test-id={'field--' + field.key}
                    />
                </div>
            );
        }
    }
}

interface IProps {
    items: Array<IVocabularyItem>;
    schemaFields: Array<ISchemaField>;
    newItemTemplate: any;
    setDirty(): void;
    setItemsValid(valid: boolean): void;
}

interface IState {
    items: Array<IVocabularyItemWithId>;
    page: number;
    searchTerm: string;
    sortDropdownOpen: boolean;
    sort: ISortOption | null;
    errorMessage: string | null;
    languages: Array<ILanguage> | null;
}

export class VocabularyItemsViewEdit extends React.Component<IProps, IState> {
    static propTypes: any;
    static defaultProps: any;
    sortFields: Array<string>;
    lastId: number;
    generateId: () => string;
    setDirtyOnce: () => void;
    setValidItemsDebounced: () => void;

    constructor(props: IProps) {
        super(props);

        this.sortFields = this.props.schemaFields.map((field) => field.key);
        this.lastId = 0;
        this.generateId = () => (this.lastId++).toString();

        const initialSortOption: ISortOption = {
            field: this.sortFields.includes('name') ? 'name' : this.sortFields[0],
            direction: 'ascending',
        };

        this.state = {
            items: sortItems(
                props.items.map((item) => ({...item, id: this.generateId()})),
                initialSortOption,
            ),
            page: 1,
            searchTerm: '',
            sortDropdownOpen: false,
            sort: initialSortOption,
            errorMessage: null,
            languages: null,
        };

        this.updateItem = this.updateItem.bind(this);
        this.removeItem = this.removeItem.bind(this);
        this.addItem = this.addItem.bind(this);
        this.getItemsForSaving = this.getItemsForSaving.bind(this);
        this.setErrorMessage = this.setErrorMessage.bind(this);

        this.setDirtyOnce = once(this.props.setDirty);
        this.setValidItemsDebounced = debounce(
            () => {
                this.props.setItemsValid(!containsInvalidItems(this.state.items, this.props.schemaFields));
            },
            200,
        );
    }

    private updateItem(item: any, key: string, value: any) {
        this.setState({
            items: this.state.items.map((_item) => {
                if (item === _item) {
                    return {..._item, [key]: value};
                } else {
                    return _item;
                }
            }),
        });
    }

    private removeItem(item: any) {
        this.setState({
            items: this.state.items.filter((_item) => _item !== item),
        });
    }

    private addItem() {
        this.setState({
            items: [{...this.props.newItemTemplate, id: this.generateId()}].concat(this.state.items),
        });
    }

    // tslint:disable-next-line: member-access
    public getItemsForSaving(): Array<IVocabularyItem> { // will be used from a ref
        return this.state.items.map((item) => {
            const nextItem = {...item};

            delete nextItem['id'];

            return nextItem;
        });
    }

    // tslint:disable-next-line: member-access
    public setErrorMessage(message: string | null) {
        this.setState({errorMessage: message});
    }

    componentDidMount() {
        this.setValidItemsDebounced();

        dataApi.query<ILanguage>(
            'languages',
            1,
            {field: 'language', direction: 'ascending'},
            {},
        ).then((res) => {
            this.setState({languages: res._items});
        });
    }

    componentDidUpdate(prevProps: IProps, prevState: IState) {
        const sortOptionChanged = prevState.sort !== this.state.sort;

        if (sortOptionChanged) {
            // eslint-disable-next-line react/no-did-update-set-state
            this.setState({
                items: sortItems(this.state.items, this.state.sort),
            });
        }

        if (this.state.items !== prevState.items) {
            this.setDirtyOnce();
            this.setValidItemsDebounced();
        }
    }

    render() {
        const {languages} = this.state;

        if (languages == null) {
            return null;
        }

        const takeFrom = (this.state.page - 1) * pageSize;
        const takeTo = this.state.page * pageSize;
        const filteredItems = this.state.searchTerm.length < 1
            ? this.state.items
            : this.state.items.filter((item) => {
                return item.qcode?.toLocaleLowerCase().includes(this.state.searchTerm)
                    || item.name?.toLocaleLowerCase().includes(this.state.searchTerm);
            });

        return (
            <React.Fragment>
                <div className="subnav">
                    <div className="flat-searchbar extended">
                        <div className="search-handler" role="search">
                            <label
                                className="trigger-icon"
                                htmlFor="vocabulary-search"
                                aria-label={gettext('Toggle search')}
                            >
                                <i className="icon-search" />
                            </label>
                            <input
                                id="vocabulary-search"
                                type="text"
                                value={this.state.searchTerm}
                                placeholder={gettext('Search')}
                                onChange={(event) => {
                                    this.setState({searchTerm: event.target.value, page: 1});
                                }}
                            />
                            <button aria-label={gettext('Clear search')} className="search-close">
                                <i className="icon-remove-sign" />
                            </button>
                        </div>
                    </div>

                    <div className="sortbar sd-margin-start--auto">
                        {this.state.sort == null ? null : (
                            <Dropdown
                                isOpen={this.state.sortDropdownOpen}
                            >
                                <button
                                    className="dropdown__toggle"
                                    onClick={() => this.setState({sortDropdownOpen: !this.state.sortDropdownOpen})}
                                >
                                    {this.state.sort.field}
                                    <span className="dropdown__caret" />
                                </button>
                                <Menu
                                    isOpen={this.state.sortDropdownOpen}
                                >
                                    {this.sortFields.map((field) => {
                                        return (
                                            <li key={field}>
                                                <button
                                                    onClick={() => {
                                                        this.setState({
                                                            sort: {...this.state.sort, field},
                                                            sortDropdownOpen: false,
                                                        });
                                                    }}
                                                >
                                                    {field}
                                                </button>
                                            </li>
                                        );
                                    })}
                                </Menu>
                            </Dropdown>
                        )}

                        {this.state.sort == null ? null : (
                            <button
                                className="icn-btn direction"
                                onClick={() => {
                                    const nextSortOption: ISortOption = {
                                        ...this.state.sort,
                                        direction: this.state.sort.direction === 'ascending'
                                            ? 'descending'
                                            : 'ascending',
                                    };

                                    this.setState({
                                        sort: nextSortOption,
                                    });
                                }}
                            >
                                {this.state.sort.direction === 'ascending'
                                    ? <i className="icon-descending" />
                                    : <i className="icon-ascending" />
                                }
                            </button>
                        )}

                        <button
                            className="btn btn--primary"
                            onClick={() => {
                            // clearing search before adding an item so it doesn't get filtered
                                this.setState({searchTerm: ''}, () => {
                                    this.addItem();

                                    // using a timeout to wait for this.state.items to update after adding an item
                                    // in case adding an item causes page count to increase
                                    setTimeout(() => {
                                        this.setState({page: 1});
                                    });
                                });
                            }}
                        >
                            <i className="icon-plus-sign" />
                            <span>{gettext('Add Item')}</span>
                        </button>
                    </div>
                </div>

                <div className="subnav pagination--rounded">
                    <ReactPaginate
                        previousLabel={gettext('prev')}
                        nextLabel={gettext('next')}
                        pageCount={getPageCount(filteredItems)}
                        marginPagesDisplayed={2}
                        pageRangeDisplayed={5}
                        onPageChange={({selected}) => {
                            this.setState({page: selected + 1});
                        }}
                        forcePage={this.state.page - 1}
                        containerClassName="bs-pagination"
                        activeClassName="active"
                    />
                </div>

                <div className="sd-padding-x--3 table-list">
                    <table data-test-id="vocabulary-items-view-edit">
                        <thead>
                            <tr>
                                {this.props.schemaFields.map((field) => (
                                    <th key={field.key}>
                                        <label>{field.label || field.key}</label>
                                    </th>
                                ))}
                                <th>
                                    <label>{gettext('Active')}</label>
                                </th>
                                <th />
                            </tr>
                        </thead>
                        <tbody>
                            {filteredItems.slice(takeFrom, takeTo).map((item) => {
                                return (
                                    <React.Fragment key={item.id}>
                                        <tr className="add-border-top">
                                            {this.props.schemaFields.map((field) => {
                                                return (
                                                    <td key={field.key}>
                                                        <InputField
                                                            field={field}
                                                            item={item}
                                                            required={field.required}
                                                            update={this.updateItem}
                                                        />

                                                        {
                                                            field.key === 'name' && (
                                                                <div style={{paddingBlockStart: 20}}>
                                                                    <ManageVocabularyItemTranslations
                                                                        item={item}
                                                                        update={(_field, value) => {
                                                                            this.updateItem(
                                                                                item,
                                                                                _field as string,
                                                                                value,
                                                                            );
                                                                        }}
                                                                        languages={languages}
                                                                    />
                                                                </div>
                                                            )
                                                        }
                                                    </td>
                                                );
                                            })}
                                            <td>
                                                <span className="vocabularyStatus">
                                                    <input
                                                        type="checkbox"
                                                        checked={!!item.is_active}
                                                        onChange={
                                                            () => this.updateItem(item, 'is_active', !item.is_active)
                                                        }
                                                    />
                                                </span>
                                            </td>
                                            <td>
                                                <button
                                                    className="icn-btn"
                                                    onClick={() => {
                                                        this.removeItem(item);
                                                    }}
                                                    data-test-id="remove"
                                                >
                                                    <i className="icon-close-small" />
                                                </button>
                                            </td>
                                        </tr>
                                    </React.Fragment>
                                );
                            })}
                        </tbody>
                    </table>

                    {
                        this.state.errorMessage == null ? null : (
                            <div className="sd-line-input sd-line-input--invalid">
                                <p className="sd-line-input__message">{this.state.errorMessage}</p>
                            </div>
                        )
                    }
                </div>
            </React.Fragment>
        );
    }
}