From a6d6e9ec9ef0f6c13e88f1785366ba8e5cf08156 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <i.kamalov@adguard.com>
Date: Thu, 28 Nov 2019 14:47:06 +0300
Subject: [PATCH] + client: add multiple fields client form

---
 client/src/__locales/en.json                  |   3 +-
 client/src/actions/clients.js                 |  25 +--
 client/src/api/Api.js                         |   7 +
 .../Settings/Clients/ClientsTable.js          |  43 ++--
 .../src/components/Settings/Clients/Form.js   | 201 +++++++++++-------
 client/src/components/ui/Icons.css            |   5 +
 client/src/components/ui/Icons.js             | Bin 36128 -> 36627 bytes
 client/src/helpers/form.js                    |  45 ++++
 client/src/helpers/formatClientCell.js        |   4 +-
 client/src/helpers/helpers.js                 |  14 ++
 10 files changed, 212 insertions(+), 135 deletions(-)

diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index 764d1450..4e834147 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -299,9 +299,10 @@
     "client_edit": "Edit Client",
     "client_identifier": "Identifier",
     "ip_address": "IP address",
-    "client_identifier_desc": "Clients can be identified by the IP address or MAC address. Please note, that using MAC as identifier is possible only if AdGuard Home is also a <0>DHCP server</0>",
+    "client_identifier_desc": "Clients can be identified by the IP address, MAC address, CIDR. Please note, that using MAC as identifier is possible only if AdGuard Home is also a <0>DHCP server</0>",
     "form_enter_ip": "Enter IP",
     "form_enter_mac": "Enter MAC",
+    "form_enter_id": "Enter identifier",
     "form_client_name": "Enter client name",
     "client_global_settings": "Use global settings",
     "client_deleted": "Client \"{{key}}\" successfully deleted",
diff --git a/client/src/actions/clients.js b/client/src/actions/clients.js
index 3974a38c..b6fcf011 100644
--- a/client/src/actions/clients.js
+++ b/client/src/actions/clients.js
@@ -2,7 +2,6 @@ import { createAction } from 'redux-actions';
 import { t } from 'i18next';
 import apiClient from '../api/Api';
 import { addErrorToast, addSuccessToast, getClients } from './index';
-import { CLIENT_ID } from '../helpers/constants';
 
 export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL');
 
@@ -13,18 +12,7 @@ export const addClientSuccess = createAction('ADD_CLIENT_SUCCESS');
 export const addClient = config => async (dispatch) => {
     dispatch(addClientRequest());
     try {
-        let data;
-        if (config.identifier === CLIENT_ID.MAC) {
-            const { ip, identifier, ...values } = config;
-
-            data = { ...values };
-        } else {
-            const { mac, identifier, ...values } = config;
-
-            data = { ...values };
-        }
-
-        await apiClient.addClient(data);
+        await apiClient.addClient(config);
         dispatch(addClientSuccess());
         dispatch(toggleClientModal());
         dispatch(addSuccessToast(t('client_added', { key: config.name })));
@@ -59,16 +47,7 @@ export const updateClientSuccess = createAction('UPDATE_CLIENT_SUCCESS');
 export const updateClient = (config, name) => async (dispatch) => {
     dispatch(updateClientRequest());
     try {
-        let data;
-        if (config.identifier === CLIENT_ID.MAC) {
-            const { ip, identifier, ...values } = config;
-
-            data = { name, data: { ...values } };
-        } else {
-            const { mac, identifier, ...values } = config;
-
-            data = { name, data: { ...values } };
-        }
+        const data = { name, data: { ...config } };
 
         await apiClient.updateClient(data);
         dispatch(updateClientSuccess());
diff --git a/client/src/api/Api.js b/client/src/api/Api.js
index 470577a8..72d6d527 100644
--- a/client/src/api/Api.js
+++ b/client/src/api/Api.js
@@ -353,6 +353,7 @@ class Api {
 
     // Per-client settings
     GET_CLIENTS = { path: 'clients', method: 'GET' };
+    FIND_CLIENTS = { path: 'clients/find', method: 'GET' };
     ADD_CLIENT = { path: 'clients/add', method: 'POST' };
     DELETE_CLIENT = { path: 'clients/delete', method: 'POST' };
     UPDATE_CLIENT = { path: 'clients/update', method: 'POST' };
@@ -389,6 +390,12 @@ class Api {
         return this.makeRequest(path, method, parameters);
     }
 
+    findClients(params) {
+        const { path, method } = this.FIND_CLIENTS;
+        const url = getPathWithQueryString(path, params);
+        return this.makeRequest(url, method);
+    }
+
     // DNS access settings
     ACCESS_LIST = { path: 'access/list', method: 'GET' };
     ACCESS_SET = { path: 'access/set', method: 'POST' };
diff --git a/client/src/components/Settings/Clients/ClientsTable.js b/client/src/components/Settings/Clients/ClientsTable.js
index de1c166f..50bb48ab 100644
--- a/client/src/components/Settings/Clients/ClientsTable.js
+++ b/client/src/components/Settings/Clients/ClientsTable.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { Trans, withNamespaces } from 'react-i18next';
 import ReactTable from 'react-table';
 
-import { MODAL_TYPE, CLIENT_ID } from '../../../helpers/constants';
+import { MODAL_TYPE } from '../../../helpers/constants';
 import Card from '../../ui/Card';
 import Modal from './Modal';
 import WrapCell from './WrapCell';
@@ -40,10 +40,7 @@ class ClientsTable extends Component {
         const client = clients.find(item => name === item.name);
 
         if (client) {
-            const identifier = client.mac ? CLIENT_ID.MAC : CLIENT_ID.IP;
-
             return {
-                identifier,
                 use_global_settings: true,
                 use_global_blocked_services: true,
                 ...client,
@@ -51,7 +48,7 @@ class ClientsTable extends Component {
         }
 
         return {
-            identifier: CLIENT_ID.IP,
+            ids: [''],
             use_global_settings: true,
             use_global_blocked_services: true,
         };
@@ -76,28 +73,22 @@ class ClientsTable extends Component {
     columns = [
         {
             Header: this.props.t('table_client'),
-            accessor: 'ip',
+            accessor: 'ids',
             minWidth: 150,
             Cell: (row) => {
-                if (row.original && row.original.mac) {
-                    return (
-                        <div className="logs__row logs__row--overflow">
-                            <span className="logs__text" title={row.original.mac}>
-                                {row.original.mac} <em>(MAC)</em>
-                            </span>
-                        </div>
-                    );
-                } else if (row.value) {
-                    return (
-                        <div className="logs__row logs__row--overflow">
-                            <span className="logs__text" title={row.value}>
-                                {row.value} <em>(IP)</em>
-                            </span>
-                        </div>
-                    );
-                }
+                const { value } = row;
 
-                return '';
+                return (
+                    <div className="logs__row logs__row--overflow">
+                        <span className="logs__text">
+                            {value.map(address => (
+                                <div key={address} title={address}>
+                                    {address}
+                                </div>
+                            ))}
+                        </span>
+                    </div>
+                );
             },
         },
         {
@@ -119,9 +110,7 @@ class ClientsTable extends Component {
 
                 return (
                     <div className="logs__row logs__row--overflow">
-                        <div className="logs__text" title={title}>
-                            {title}
-                        </div>
+                        <div className="logs__text">{title}</div>
                     </div>
                 );
             },
diff --git a/client/src/components/Settings/Clients/Form.js b/client/src/components/Settings/Clients/Form.js
index 2bf6679f..6e3ceced 100644
--- a/client/src/components/Settings/Clients/Form.js
+++ b/client/src/components/Settings/Clients/Form.js
@@ -1,14 +1,20 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import { Field, reduxForm, formValueSelector } from 'redux-form';
+import { Field, FieldArray, reduxForm, formValueSelector } from 'redux-form';
 import { Trans, withNamespaces } from 'react-i18next';
 import flow from 'lodash/flow';
 
+import i18n from '../../../i18n';
 import Tabs from '../../ui/Tabs';
 import { toggleAllServices } from '../../../helpers/helpers';
-import { renderField, renderRadioField, renderSelectField, renderServiceField, ip, mac, required } from '../../../helpers/form';
-import { CLIENT_ID, SERVICES } from '../../../helpers/constants';
+import {
+    renderField,
+    renderGroupField,
+    renderSelectField,
+    renderServiceField,
+} from '../../../helpers/form';
+import { SERVICES } from '../../../helpers/constants';
 import './Service.css';
 
 const settingsCheckboxes = [
@@ -34,6 +40,67 @@ const settingsCheckboxes = [
     },
 ];
 
+const validate = (values) => {
+    const errors = {};
+    const { name, ids } = values;
+
+    if (!name || !name.length) {
+        errors.name = i18n.t('form_error_required');
+    }
+
+    if (ids && ids.length) {
+        const idArrayErrors = [];
+        ids.forEach((id, idx) => {
+            if (!id || !id.length) {
+                idArrayErrors[idx] = i18n.t('form_error_required');
+            }
+        });
+
+        if (idArrayErrors.length) {
+            errors.ids = idArrayErrors;
+        }
+    }
+
+    return errors;
+};
+
+const renderFields = (placeholder, buttonTitle) =>
+    function cell(row) {
+        const {
+            fields,
+            meta: { error },
+        } = row;
+
+        return (
+            <div className="form__group">
+                {fields.map((ip, index) => (
+                    <div key={index} className="mb-1">
+                        <Field
+                            name={ip}
+                            component={renderGroupField}
+                            type="text"
+                            className="form-control"
+                            placeholder={placeholder}
+                            isActionAvailable={index !== 0}
+                            removeField={() => fields.remove(index)}
+                        />
+                    </div>
+                ))}
+                <button
+                    type="button"
+                    className="btn btn-link btn-block btn-sm"
+                    onClick={() => fields.push()}
+                    title={buttonTitle}
+                >
+                    <svg className="icon icon--close">
+                        <use xlinkHref="#plus" />
+                    </svg>
+                </button>
+                {error && <div className="error">{error}</div>}
+            </div>
+        );
+    };
+
 let Form = (props) => {
     const {
         t,
@@ -42,92 +109,53 @@ let Form = (props) => {
         change,
         pristine,
         submitting,
-        clientIdentifier,
         useGlobalSettings,
         useGlobalServices,
         toggleClientModal,
         processingAdding,
         processingUpdating,
+        invalid,
     } = props;
 
     return (
         <form onSubmit={handleSubmit}>
             <div className="modal-body">
-                <div className="form__group">
-                    <div className="form__inline mb-2">
-                        <strong className="mr-3">
-                            <Trans>client_identifier</Trans>
-                        </strong>
-                        <div className="custom-controls-stacked">
-                            <Field
-                                name="identifier"
-                                component={renderRadioField}
-                                type="radio"
-                                className="form-control mr-2"
-                                value="ip"
-                                placeholder={t('ip_address')}
-                            />
-                            <Field
-                                name="identifier"
-                                component={renderRadioField}
-                                type="radio"
-                                className="form-control mr-2"
-                                value="mac"
-                                placeholder="MAC"
-                            />
+                <div className="form__group mb-0">
+                    <div className="form__group">
+                        <Field
+                            id="name"
+                            name="name"
+                            component={renderField}
+                            type="text"
+                            className="form-control"
+                            placeholder={t('form_client_name')}
+                        />
+                    </div>
+
+                    <div className="form__group">
+                        <div className="form__label">
+                            <strong className="mr-3">
+                                <Trans>client_identifier</Trans>
+                            </strong>
+                        </div>
+                        <div className="form__desc mt-0">
+                            <Trans
+                                components={[
+                                    <a href="#dhcp" key="0">
+                                        link
+                                    </a>,
+                                ]}
+                            >
+                                client_identifier_desc
+                            </Trans>
                         </div>
                     </div>
-                    <div className="row">
-                        <div className="col col-sm-6">
-                            {clientIdentifier === CLIENT_ID.IP && (
-                                <div className="form__group">
-                                    <Field
-                                        id="ip"
-                                        name="ip"
-                                        component={renderField}
-                                        type="text"
-                                        className="form-control"
-                                        placeholder={t('form_enter_ip')}
-                                        validate={[ip, required]}
-                                    />
-                                </div>
-                            )}
-                            {clientIdentifier === CLIENT_ID.MAC && (
-                                <div className="form__group">
-                                    <Field
-                                        id="mac"
-                                        name="mac"
-                                        component={renderField}
-                                        type="text"
-                                        className="form-control"
-                                        placeholder={t('form_enter_mac')}
-                                        validate={[mac, required]}
-                                    />
-                                </div>
-                            )}
-                        </div>
-                        <div className="col col-sm-6">
-                            <Field
-                                id="name"
-                                name="name"
-                                component={renderField}
-                                type="text"
-                                className="form-control"
-                                placeholder={t('form_client_name')}
-                                validate={[required]}
-                            />
-                        </div>
-                    </div>
-                    <div className="form__desc">
-                        <Trans
-                            components={[
-                                <a href="#dhcp" key="0">
-                                    link
-                                </a>,
-                            ]}
-                        >
-                            client_identifier_desc
-                        </Trans>
+
+                    <div className="form__group">
+                        <FieldArray
+                            name="ids"
+                            component={renderFields(t('form_enter_id'), t('form_add_id'))}
+                        />
                     </div>
                 </div>
 
@@ -140,7 +168,11 @@ let Form = (props) => {
                                     type="checkbox"
                                     component={renderSelectField}
                                     placeholder={t(setting.placeholder)}
-                                    disabled={setting.name !== 'use_global_settings' ? useGlobalSettings : false}
+                                    disabled={
+                                        setting.name !== 'use_global_settings'
+                                            ? useGlobalSettings
+                                            : false
+                                    }
                                 />
                             </div>
                         ))}
@@ -210,7 +242,13 @@ let Form = (props) => {
                     <button
                         type="submit"
                         className="btn btn-success btn-standard"
-                        disabled={submitting || pristine || processingAdding || processingUpdating}
+                        disabled={
+                            submitting ||
+                            invalid ||
+                            pristine ||
+                            processingAdding ||
+                            processingUpdating
+                        }
                     >
                         <Trans>save_btn</Trans>
                     </button>
@@ -227,22 +265,20 @@ Form.propTypes = {
     change: PropTypes.func.isRequired,
     submitting: PropTypes.bool.isRequired,
     toggleClientModal: PropTypes.func.isRequired,
-    clientIdentifier: PropTypes.string,
     useGlobalSettings: PropTypes.bool,
     useGlobalServices: PropTypes.bool,
     t: PropTypes.func.isRequired,
     processingAdding: PropTypes.bool.isRequired,
     processingUpdating: PropTypes.bool.isRequired,
+    invalid: PropTypes.bool.isRequired,
 };
 
 const selector = formValueSelector('clientForm');
 
 Form = connect((state) => {
-    const clientIdentifier = selector(state, 'identifier');
     const useGlobalSettings = selector(state, 'use_global_settings');
     const useGlobalServices = selector(state, 'use_global_blocked_services');
     return {
-        clientIdentifier,
         useGlobalSettings,
         useGlobalServices,
     };
@@ -253,5 +289,6 @@ export default flow([
     reduxForm({
         form: 'clientForm',
         enableReinitialize: true,
+        validate,
     }),
 ])(Form);
diff --git a/client/src/components/ui/Icons.css b/client/src/components/ui/Icons.css
index 17d608fd..da2c5f4e 100644
--- a/client/src/components/ui/Icons.css
+++ b/client/src/components/ui/Icons.css
@@ -3,3 +3,8 @@
     vertical-align: middle;
     height: 100%;
 }
+
+.icon--close {
+    width: 24px;
+    height: 24px;
+}
diff --git a/client/src/components/ui/Icons.js b/client/src/components/ui/Icons.js
index 100e74de8c015e06d790d3b0106aca3500070891..24d05e65e7a06a86b47d208c2b6ea41a34e8ae95 100644
GIT binary patch
delta 224
zcmZ25i)r#arVYP)Ca+f%Wlb*1FD{;(P$V|_o}$>~03Dsl4;18max(K$6)FsEl?*ME
z6e@wVnUX?<5tyn3Qb18V8-0)>I~%xCpb{jNV2xm<lkck;vJ~W$V%Xvb*J@}4(Q68^
i1<VEMwZvi#Pz}f$kQ$JSfNB)LPBH@8BQyDTk17DE0YY>D

delta 13
VcmbO{k7>ayrVYP)Cco=b1pqFA2HOAt

diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js
index fc0286a1..55e0b0a0 100644
--- a/client/src/helpers/form.js
+++ b/client/src/helpers/form.js
@@ -29,6 +29,50 @@ export const renderField = ({
     </Fragment>
 );
 
+export const renderGroupField = ({
+    input,
+    id,
+    className,
+    placeholder,
+    type,
+    disabled,
+    autoComplete,
+    isActionAvailable,
+    removeField,
+    meta: { touched, error },
+}) => (
+    <Fragment>
+        <div className="input-group">
+            <input
+                {...input}
+                id={id}
+                placeholder={placeholder}
+                type={type}
+                className={className}
+                disabled={disabled}
+                autoComplete={autoComplete}
+            />
+            {isActionAvailable &&
+                <span className="input-group-append">
+                    <button
+                        type="button"
+                        className="btn btn-secondary btn-icon"
+                        onClick={removeField}
+                    >
+                        <svg className="icon icon--close">
+                            <use xlinkHref="#cross" />
+                        </svg>
+                    </button>
+                </span>
+            }
+        </div>
+
+        {!disabled &&
+            touched &&
+            (error && <span className="form__message form__message--error">{error}</span>)}
+    </Fragment>
+);
+
 export const renderRadioField = ({
     input, placeholder, disabled, meta: { touched, error },
 }) => (
@@ -102,6 +146,7 @@ export const renderServiceField = ({
     </Fragment>
 );
 
+// Validation functions
 export const required = (value) => {
     if (value || value === 0) {
         return false;
diff --git a/client/src/helpers/formatClientCell.js b/client/src/helpers/formatClientCell.js
index 931210a7..30e9e99b 100644
--- a/client/src/helpers/formatClientCell.js
+++ b/client/src/helpers/formatClientCell.js
@@ -1,5 +1,5 @@
 import React, { Fragment } from 'react';
-import { getClientInfo, normalizeWhois } from './helpers';
+import { getClientInfo, getAutoClientInfo, normalizeWhois } from './helpers';
 import { WHOIS_ICONS } from './constants';
 
 const getFormattedWhois = (whois, t) => {
@@ -23,7 +23,7 @@ const getFormattedWhois = (whois, t) => {
 };
 
 export const formatClientCell = (value, clients, autoClients, t) => {
-    const clientInfo = getClientInfo(clients, value) || getClientInfo(autoClients, value);
+    const clientInfo = getClientInfo(clients, value) || getAutoClientInfo(autoClients, value);
     const { name, whois } = clientInfo;
     let whoisContainer = '';
     let nameContainer = value;
diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js
index ced2e9ad..82389111 100644
--- a/client/src/helpers/helpers.js
+++ b/client/src/helpers/helpers.js
@@ -248,6 +248,20 @@ export const redirectToCurrentProtocol = (values, httpPort = 80) => {
 export const normalizeTextarea = text => text && text.replace(/[;, ]/g, '\n').split('\n').filter(n => n);
 
 export const getClientInfo = (clients, ip) => {
+    const client = clients
+        .find(item => item.ip_addrs && item.ip_addrs.find(clientIp => clientIp === ip));
+
+    if (!client) {
+        return '';
+    }
+
+    const { name, whois_info } = client;
+    const whois = Object.keys(whois_info).length > 0 ? whois_info : '';
+
+    return { name, whois };
+};
+
+export const getAutoClientInfo = (clients, ip) => {
     const client = clients.find(item => ip === item.ip);
 
     if (!client) {