From 2d5287fcf31dfa7b484da9d01cbdc9f18584f56a Mon Sep 17 00:00:00 2001
From: ArtemBaskal <a.baskal@adguard.com>
Date: Fri, 3 Jul 2020 16:53:53 +0300
Subject: [PATCH] Add DNS cache setting UI

---
 client/src/__locales/en.json                  |  15 ++-
 .../src/components/Settings/Dns/Cache/Form.js | 100 ++++++++++++++++++
 .../components/Settings/Dns/Cache/index.js    |  42 ++++++++
 client/src/components/Settings/Dns/index.js   |   7 +-
 client/src/helpers/constants.js               |   1 +
 client/src/helpers/form.js                    |  15 ++-
 client/src/helpers/helpers.js                 |  12 +++
 7 files changed, 187 insertions(+), 5 deletions(-)
 create mode 100644 client/src/components/Settings/Dns/Cache/Form.js
 create mode 100644 client/src/components/Settings/Dns/Cache/index.js

diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index f1178b81..0e82a444 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -206,6 +206,8 @@
     "anonymize_client_ip": "Anonymize client IP",
     "anonymize_client_ip_desc": "Don't save the full IP address of the client in logs and statistics",
     "dns_config": "DNS server configuration",
+    "dns_cache_config": "DNS cache configuration",
+    "dns_cache_config_desc": "Here you can configure DNS cache",
     "blocking_mode": "Blocking mode",
     "default": "Default",
     "nxdomain": "NXDOMAIN",
@@ -491,5 +493,16 @@
     "list_updated": "{{count}} list updated",
     "list_updated_plural": "{{count}} lists updated",
     "dnssec_enable": "Enable DNSSEC",
-    "dnssec_enable_desc": "Set DNSSEC flag in the outcoming DNS queries and check the result (DNSSEC-enabled resolver is required)"
+    "dnssec_enable_desc": "Set DNSSEC flag in the outcoming DNS queries and check the result (DNSSEC-enabled resolver is required)",
+    "cache_size": "Cache size",
+    "cache_size_desc": "DNS cache size (in bytes)",
+    "cache_ttl_min_override": "Override minimum TTL",
+    "cache_ttl_max_override": "Override maximum TTL",
+    "enter_cache_size": "Enter cache size",
+    "enter_cache_ttl_min_override": "Enter minimum TTL",
+    "enter_cache_ttl_max_override": "Enter maximum TTL",
+    "cache_ttl_min_override_desc": "Override TTL value (minimum) received from upstream server. This value can't larger than 3600 (1 hour)",
+    "cache_ttl_max_override_desc": "Override TTL value (maximum) received from upstream server",
+    "min_exceeds_max_value": "Minimum value exceeds maximum value",
+    "value_not_larger_than": "Value can't be larger than {{maximum}}"
 }
diff --git a/client/src/components/Settings/Dns/Cache/Form.js b/client/src/components/Settings/Dns/Cache/Form.js
new file mode 100644
index 00000000..c1314e89
--- /dev/null
+++ b/client/src/components/Settings/Dns/Cache/Form.js
@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Field, reduxForm } from 'redux-form';
+import { Trans, useTranslation } from 'react-i18next';
+import { shallowEqual, useSelector } from 'react-redux';
+import {
+    biggerOrEqualZero,
+    maxValue,
+    renderInputField,
+    required,
+    toNumber,
+} from '../../../../helpers/form';
+import { FORM_NAME } from '../../../../helpers/constants';
+
+const maxValue3600 = maxValue(3600);
+
+const getInputFields = ({ required, maxValue3600 }) => [{
+    name: 'cache_size',
+    title: 'cache_size',
+    description: 'cache_size_desc',
+    placeholder: 'enter_cache_size',
+    validate: required,
+},
+{
+    name: 'cache_ttl_min',
+    title: 'cache_ttl_min_override',
+    description: 'cache_ttl_min_override_desc',
+    placeholder: 'enter_cache_ttl_min_override',
+    max: 3600,
+    validate: maxValue3600,
+},
+{
+    name: 'cache_ttl_max',
+    title: 'cache_ttl_max_override',
+    description: 'cache_ttl_max_override_desc',
+    placeholder: 'enter_cache_ttl_max_override',
+}];
+
+const Form = ({
+    handleSubmit, submitting, invalid,
+}) => {
+    const { t } = useTranslation();
+
+    const { processingSetConfig } = useSelector((state) => state.dnsConfig, shallowEqual);
+    const {
+        cache_ttl_max, cache_ttl_min,
+    } = useSelector((state) => state.form[FORM_NAME.CACHE].values, shallowEqual);
+
+    const minExceedsMax = cache_ttl_min > cache_ttl_max;
+
+    const INPUTS_FIELDS = getInputFields({
+        required,
+        maxValue3600,
+    });
+
+    return <form onSubmit={handleSubmit}>
+        <div className="row">
+            {INPUTS_FIELDS.map(({
+                name, title, description, placeholder, validate, max,
+            }) => <div className="col-12" key={name}>
+                <div className="col-7 p-0">
+                    <div className="form__group form__group--settings">
+                        <label htmlFor={name}
+                               className="form__label form__label--with-desc">{t(title)}</label>
+                        <div className="form__desc form__desc--top">{t(description)}</div>
+                        <Field
+                            name={name}
+                            type="number"
+                            component={renderInputField}
+                            placeholder={t(placeholder)}
+                            disabled={processingSetConfig}
+                            normalize={toNumber}
+                            className="form-control"
+                            validate={[biggerOrEqualZero].concat(validate || [])}
+                            min={0}
+                            max={max}
+                        />
+                    </div>
+                </div>
+            </div>)}
+            {minExceedsMax
+            && <span className="text-danger pl-3 pb-3">{t('min_exceeds_max_value')}</span>}
+        </div>
+        <button
+            type="submit"
+            className="btn btn-success btn-standard btn-large"
+            disabled={submitting || invalid || processingSetConfig || minExceedsMax}
+        >
+            <Trans>save_btn</Trans>
+        </button>
+    </form>;
+};
+
+Form.propTypes = {
+    handleSubmit: PropTypes.func.isRequired,
+    submitting: PropTypes.bool.isRequired,
+    invalid: PropTypes.bool.isRequired,
+};
+
+export default reduxForm({ form: FORM_NAME.CACHE })(Form);
diff --git a/client/src/components/Settings/Dns/Cache/index.js b/client/src/components/Settings/Dns/Cache/index.js
new file mode 100644
index 00000000..22b8b7b6
--- /dev/null
+++ b/client/src/components/Settings/Dns/Cache/index.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { shallowEqual, useDispatch, useSelector } from 'react-redux';
+import Card from '../../../ui/Card';
+import Form from './Form';
+import { setDnsConfig } from '../../../../actions/dnsConfig';
+import { selectCompletedFields } from '../../../../helpers/helpers';
+
+const CacheConfig = () => {
+    const { t } = useTranslation();
+    const dispatch = useDispatch();
+    const {
+        cache_size, cache_ttl_max, cache_ttl_min,
+    } = useSelector((state) => state.dnsConfig, shallowEqual);
+
+    const handleFormSubmit = (values) => {
+        const completedFields = selectCompletedFields(values);
+        dispatch(setDnsConfig(completedFields));
+    };
+
+    return (
+        <Card
+            title={t('dns_cache_config')}
+            subtitle={t('dns_cache_config_desc')}
+            bodyType="card-body box-body--settings"
+            id="dns-config"
+        >
+            <div className="form">
+                <Form
+                    initialValues={{
+                        cache_size,
+                        cache_ttl_max,
+                        cache_ttl_min,
+                    }}
+                    onSubmit={handleFormSubmit}
+                />
+            </div>
+        </Card>
+    );
+};
+
+export default CacheConfig;
diff --git a/client/src/components/Settings/Dns/index.js b/client/src/components/Settings/Dns/index.js
index 5ed8f9ca..41993f89 100644
--- a/client/src/components/Settings/Dns/index.js
+++ b/client/src/components/Settings/Dns/index.js
@@ -7,9 +7,10 @@ import Access from './Access';
 import Config from './Config';
 import PageTitle from '../../ui/PageTitle';
 import Loading from '../../ui/Loading';
+import CacheConfig from './Cache';
 
 const Dns = (props) => {
-    const [t] = useTranslation();
+    const { t } = useTranslation();
 
     useEffect(() => {
         props.getAccessList();
@@ -40,6 +41,10 @@ const Dns = (props) => {
                         dnsConfig={dnsConfig}
                         setDnsConfig={setDnsConfig}
                     />
+                    <CacheConfig
+                        dnsConfig={dnsConfig}
+                        setDnsConfig={setDnsConfig}
+                    />
                     <Access access={access} setAccessList={setAccessList} />
                 </>}
         </>
diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js
index 60c22264..9d587d9e 100644
--- a/client/src/helpers/constants.js
+++ b/client/src/helpers/constants.js
@@ -406,4 +406,5 @@ export const FORM_NAME = {
     STATS_CONFIG: 'statsConfig',
     INSTALL: 'install',
     LOGIN: 'login',
+    CACHE: 'cache',
 };
diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js
index bace77b5..f28ec5f3 100644
--- a/client/src/helpers/form.js
+++ b/client/src/helpers/form.js
@@ -1,6 +1,7 @@
 import React, { Fragment } from 'react';
 import { Trans } from 'react-i18next';
 import PropTypes from 'prop-types';
+import i18next from 'i18next';
 import {
     R_IPV4, R_MAC, R_HOST, R_IPV6, R_CIDR, R_CIDR_IPV6,
     UNSAFE_PORTS, R_URL_REQUIRES_PROTOCOL, R_WIN_ABSOLUTE_PATH, R_UNIX_ABSOLUTE_PATH,
@@ -10,7 +11,7 @@ import { createOnBlurHandler } from './helpers';
 export const renderField = (props, elementType) => {
     const {
         input, id, className, placeholder, type, disabled, normalizeOnBlur,
-        autoComplete, meta: { touched, error },
+        autoComplete, meta: { touched, error }, min, max, step,
     } = props;
 
     const onBlur = (event) => createOnBlurHandler(event, input, normalizeOnBlur);
@@ -23,14 +24,17 @@ export const renderField = (props, elementType) => {
         autoComplete,
         disabled,
         type,
+        min,
+        max,
+        step,
         onBlur,
     });
     return (
-        <Fragment>
+        <>
             {element}
             {!disabled && touched && error
             && <span className="form__message form__message--error">{error}</span>}
-        </Fragment>
+        </>
     );
 };
 
@@ -43,6 +47,9 @@ renderField.propTypes = {
     disabled: PropTypes.bool,
     autoComplete: PropTypes.bool,
     normalizeOnBlur: PropTypes.func,
+    min: PropTypes.number,
+    max: PropTypes.number,
+    step: PropTypes.number,
     meta: PropTypes.shape({
         touched: PropTypes.bool,
         error: PropTypes.object,
@@ -238,6 +245,8 @@ export const required = (value) => {
     return <Trans>form_error_required</Trans>;
 };
 
+export const maxValue = (maximum) => (value) => (value && value > maximum ? i18next.t('value_not_larger_than', { maximum }) : undefined);
+
 export const ipv4 = (value) => {
     if (value && !R_IPV4.test(value)) {
         return <Trans>form_error_ip4_format</Trans>;
diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js
index be8e884e..3a655ac3 100644
--- a/client/src/helpers/helpers.js
+++ b/client/src/helpers/helpers.js
@@ -562,3 +562,15 @@ export const getIpMatchListStatus = (ip, list) => {
         return IP_MATCH_LIST_STATUS.NOT_FOUND;
     }
 };
+
+/**
+ * @param values {object}
+ * @returns {object}
+ */
+export const selectCompletedFields = (values) => Object.entries(values)
+    .reduce((acc, [key, value]) => {
+        if (value || value === 0) {
+            acc[key] = value;
+        }
+        return acc;
+    }, {});