summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorHaishan <[email protected]>2020-07-01 22:06:26 +0800
committerHaishan <[email protected]>2020-07-04 17:58:56 +0800
commit32bed273c83f0593187110d2b08a0f9ec5a7efd7 (patch)
tree0b47da752de3ee0d87945c1122b2cf9d3bf8043f /src/components
parent55e928a87f561ab927774834b50e099a0758522d (diff)
feat: support rule provider
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Button.module.css4
-rw-r--r--src/components/Button.tsx9
-rw-r--r--src/components/RuleSearch.js6
-rw-r--r--src/components/Rules.js115
-rw-r--r--src/components/Rules.module.css22
-rw-r--r--src/components/StateProvider.js5
-rw-r--r--src/components/proxies/TextFilter.tsx21
-rw-r--r--src/components/rules/RuleProviderItem.module.css43
-rw-r--r--src/components/rules/RuleProviderItem.tsx71
-rw-r--r--src/components/rules/TextFilter.tsx18
10 files changed, 264 insertions, 50 deletions
diff --git a/src/components/Button.module.css b/src/components/Button.module.css
index 719a9aa..83da6af 100644
--- a/src/components/Button.module.css
+++ b/src/components/Button.module.css
@@ -43,6 +43,10 @@
}
}
+.btn:disabled {
+ opacity: 0.5;
+}
+
.btnStart {
margin-right: 5px;
display: inline-flex;
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 7409c58..9450969 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -16,6 +16,7 @@ type ButtonInternalProps = {
type ButtonProps = {
isLoading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
+ disabled?: boolean;
kind?: 'primary' | 'minimal';
className?: string;
} & ButtonInternalProps;
@@ -23,6 +24,7 @@ type ButtonProps = {
function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) {
const {
onClick,
+ disabled = false,
isLoading,
kind = 'primary',
className,
@@ -43,7 +45,12 @@ function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) {
className
);
return (
- <button className={btnClassName} ref={ref} onClick={internalOnClick}>
+ <button
+ className={btnClassName}
+ ref={ref}
+ onClick={internalOnClick}
+ disabled={disabled}
+ >
{isLoading ? (
<>
<span
diff --git a/src/components/RuleSearch.js b/src/components/RuleSearch.js
deleted file mode 100644
index bc58642..0000000
--- a/src/components/RuleSearch.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { getSearchText, updateSearchText } from '../store/rules';
-import Search from './Search';
-import { connect } from './StateProvider';
-
-const mapState = (s) => ({ searchText: getSearchText(s), updateSearchText });
-export default connect(mapState)(Search);
diff --git a/src/components/Rules.js b/src/components/Rules.js
index a04116a..949e5e9 100644
--- a/src/components/Rules.js
+++ b/src/components/Rules.js
@@ -1,27 +1,63 @@
import React from 'react';
import { RotateCw } from 'react-feather';
-import { areEqual, FixedSizeList as List } from 'react-window';
+import { queryCache, useQuery } from 'react-query';
+import { areEqual, VariableSizeList } from 'react-window';
+import { useRecoilState } from 'recoil';
+import { fetchRuleProviders } from 'src/api/rule-provider';
+import { fetchRules } from 'src/api/rules';
+import { RuleProviderItem } from 'src/components/rules/RuleProviderItem';
+import { TextFilter } from 'src/components/rules/TextFilter';
+import { ruleFilterText } from 'src/store/rules';
import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight';
import { getClashAPIConfig } from '../store/app';
-import { fetchRules, fetchRulesOnce, getRules } from '../store/rules';
import ContentHeader from './ContentHeader';
import Rule from './Rule';
-import RuleSearch from './RuleSearch';
+import s from './Rules.module.css';
import { Fab, position as fabPosition } from './shared/Fab';
import { connect } from './StateProvider';
-const { memo, useEffect, useMemo, useCallback } = React;
+const { memo, useMemo, useCallback } = React;
const paddingBottom = 30;
-function itemKey(index, data) {
- const item = data[index];
+function itemKey(index, { rules, provider }) {
+ const providerQty = provider.names.length;
+
+ if (index < providerQty) {
+ return provider.names[index];
+ }
+ const item = rules[index - providerQty];
return item.id;
}
+function getItemSizeFactory({ provider }) {
+ return function getItemSize(idx) {
+ const providerQty = provider.names.length;
+ if (idx < providerQty) {
+ // provider
+ return 90;
+ }
+ // rule
+ return 80;
+ };
+}
+
const Row = memo(({ index, style, data }) => {
- const r = data[index];
+ const { rules, provider, apiConfig } = data;
+ const providerQty = provider.names.length;
+
+ if (index < providerQty) {
+ const name = provider.names[index];
+ const item = provider.byName[name];
+ return (
+ <div style={style} className={s.RuleProviderItemWrapper}>
+ <RuleProviderItem apiConfig={apiConfig} {...item} />
+ </div>
+ );
+ }
+
+ const r = rules[index - providerQty];
return (
<div style={style}>
<Rule {...r} />
@@ -31,42 +67,75 @@ const Row = memo(({ index, style, data }) => {
const mapState = (s) => ({
apiConfig: getClashAPIConfig(s),
- rules: getRules(s),
});
export default connect(mapState)(Rules);
-function Rules({ dispatch, apiConfig, rules }) {
- const fetchRulesHooked = useCallback(() => {
- dispatch(fetchRules(apiConfig));
- }, [apiConfig, dispatch]);
- useEffect(() => {
- dispatch(fetchRulesOnce(apiConfig));
- }, [dispatch, apiConfig]);
+function useRuleAndProvider(apiConfig) {
+ const { data: rules } = useQuery(['/rules', apiConfig], fetchRules, {
+ suspense: true,
+ });
+ const { data: provider } = useQuery(
+ ['/providers/rules', apiConfig],
+ fetchRuleProviders,
+ { suspense: true }
+ );
+
+ const [filterText] = useRecoilState(ruleFilterText);
+ if (filterText === '') {
+ return { rules, provider };
+ } else {
+ const f = filterText.toLowerCase();
+ return {
+ rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0),
+ provider: {
+ byName: provider.byName,
+ names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0),
+ },
+ };
+ }
+}
+
+function useInvalidateQueries() {
+ return useCallback(() => {
+ queryCache.invalidateQueries('/rules');
+ queryCache.invalidateQueries('/providers/rules');
+ }, []);
+}
+
+function Rules({ apiConfig }) {
const [refRulesContainer, containerHeight] = useRemainingViewPortHeight();
const refreshIcon = useMemo(() => <RotateCw width={16} />, []);
+
+ const { rules, provider } = useRuleAndProvider(apiConfig);
+ const invalidateQueries = useInvalidateQueries();
+
+ const getItemSize = getItemSizeFactory({ rules, provider });
+
return (
<div>
- <ContentHeader title="Rules" />
- <RuleSearch />
+ <div className={s.header}>
+ <ContentHeader title="Rules" />
+ <TextFilter />
+ </div>
<div ref={refRulesContainer} style={{ paddingBottom }}>
- <List
+ <VariableSizeList
height={containerHeight - paddingBottom}
width="100%"
- itemCount={rules.length}
- itemSize={80}
- itemData={rules}
+ itemCount={rules.length + provider.names.length}
+ itemSize={getItemSize}
+ itemData={{ rules, provider, apiConfig }}
itemKey={itemKey}
>
{Row}
- </List>
+ </VariableSizeList>
</div>
<Fab
icon={refreshIcon}
text="Refresh"
- onClick={fetchRulesHooked}
position={fabPosition}
+ onClick={invalidateQueries}
/>
</div>
);
diff --git a/src/components/Rules.module.css b/src/components/Rules.module.css
index 79a9626..6459e17 100644
--- a/src/components/Rules.module.css
+++ b/src/components/Rules.module.css
@@ -1 +1,21 @@
-/* */
+.header {
+ display: grid;
+ grid-template-columns: 1fr minmax(auto, 330px);
+ align-items: center;
+
+ /*
+ * the content header has some padding
+ * we need to apply some right padding to this container then
+ */
+ padding-right: 15px;
+ @media (--breakpoint-not-small) {
+ padding-right: 40px;
+ }
+}
+
+.RuleProviderItemWrapper {
+ padding: 6px 15px;
+ @media (--breakpoint-not-small) {
+ padding: 10px 40px;
+ }
+}
diff --git a/src/components/StateProvider.js b/src/components/StateProvider.js
index 56527aa..e905d98 100644
--- a/src/components/StateProvider.js
+++ b/src/components/StateProvider.js
@@ -1,6 +1,11 @@
import produce, * as immer from 'immer';
import React from 'react';
+// in logs store we update logs in place
+// outside of immer produce
+// this is just workaround
+immer.setAutoFreeze(false);
+
const {
createContext,
memo,
diff --git a/src/components/proxies/TextFilter.tsx b/src/components/proxies/TextFilter.tsx
index 6465572..75f1d51 100644
--- a/src/components/proxies/TextFilter.tsx
+++ b/src/components/proxies/TextFilter.tsx
@@ -1,28 +1,11 @@
-import debounce from 'lodash-es/debounce';
import * as React from 'react';
-import { useRecoilState } from 'recoil';
+import { useTextInut } from 'src/hooks/useTextInput';
import { proxyFilterText } from '../../store/proxies';
import shared from '../shared.module.css';
-const { useCallback, useState, useMemo } = React;
-
export function TextFilter() {
- const [, setTextGlobal] = useRecoilState(proxyFilterText);
- const [text, setText] = useState('');
-
- const setTextDebounced = useMemo(() => debounce(setTextGlobal, 300), [
- setTextGlobal,
- ]);
-
- const onChange = useCallback(
- (e: React.ChangeEvent<HTMLInputElement>) => {
- setText(e.target.value);
- setTextDebounced(e.target.value);
- },
- [setTextDebounced]
- );
-
+ const [onChange, text] = useTextInut(proxyFilterText);
return (
<input
className={shared.input}
diff --git a/src/components/rules/RuleProviderItem.module.css b/src/components/rules/RuleProviderItem.module.css
new file mode 100644
index 0000000..f4f52c8
--- /dev/null
+++ b/src/components/rules/RuleProviderItem.module.css
@@ -0,0 +1,43 @@
+.RuleProviderItem {
+ display: grid;
+ grid-template-columns: 40px 1fr 46px;
+ height: 100%;
+}
+
+.left {
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-text-secondary);
+ opacity: 0.4;
+}
+
+.middle {
+ display: grid;
+ grid-template-rows: 1fr auto auto;
+ align-items: center;
+}
+
+.gray {
+ color: #777;
+}
+
+.refreshButtonWrapper {
+ display: grid;
+ place-items: center;
+}
+
+.rotate {
+ display: inline-flex;
+}
+.isRotating {
+ animation: rotating 3s infinite linear;
+}
+
+@keyframes rotating {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/components/rules/RuleProviderItem.tsx b/src/components/rules/RuleProviderItem.tsx
new file mode 100644
index 0000000..3b6d93d
--- /dev/null
+++ b/src/components/rules/RuleProviderItem.tsx
@@ -0,0 +1,71 @@
+import cx from 'clsx';
+import { formatDistance } from 'date-fns';
+import * as React from 'react';
+import { RotateCw } from 'react-feather';
+import { queryCache, useMutation } from 'react-query';
+import { refreshRuleProviderByName } from 'src/api/rule-provider';
+import Button from 'src/components/Button';
+import { SectionNameType } from 'src/components/shared/Basic';
+import { ClashAPIConfig } from 'src/types';
+
+import s from './RuleProviderItem.module.css';
+
+function useRefresh(
+ name: string,
+ apiConfig: ClashAPIConfig
+): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] {
+ const [mutate, { isLoading }] = useMutation(refreshRuleProviderByName, {
+ onSuccess: () => {
+ queryCache.invalidateQueries('/providers/rules');
+ },
+ });
+
+ const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => {
+ ev.preventDefault();
+ mutate({ name, apiConfig });
+ };
+
+ return [onClickRefreshButton, isLoading];
+}
+
+function RotatableRotateCw({ isRotating }: { isRotating: boolean }) {
+ const cls = cx(s.rotate, {
+ [s.isRotating]: isRotating,
+ });
+ return (
+ <span className={cls}>
+ <RotateCw width={16} />
+ </span>
+ );
+}
+
+export function RuleProviderItem({
+ idx,
+ name,
+ vehicleType,
+ behavior,
+ updatedAt,
+ ruleCount,
+ apiConfig,
+}) {
+ const [onClickRefreshButton, isRefreshing] = useRefresh(name, apiConfig);
+ const timeAgo = formatDistance(new Date(updatedAt), new Date());
+ return (
+ <div className={s.RuleProviderItem}>
+ <span className={s.left}>{idx}</span>
+ <div className={s.middle}>
+ <SectionNameType name={name} type={`${vehicleType} / ${behavior}`} />
+ <div className={s.gray}>
+ {ruleCount < 2 ? `${ruleCount} rule` : `${ruleCount} rules`}
+ </div>
+ <small className={s.gray}>Updated {timeAgo} ago</small>
+ </div>
+ <span className={s.refreshButtonWrapper}>
+ <Button onClick={onClickRefreshButton} disabled={isRefreshing}>
+ <RotatableRotateCw isRotating={isRefreshing} />
+ </Button>
+ </span>
+ </div>
+ );
+}
+
diff --git a/src/components/rules/TextFilter.tsx b/src/components/rules/TextFilter.tsx
new file mode 100644
index 0000000..a3cc29e
--- /dev/null
+++ b/src/components/rules/TextFilter.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { useTextInut } from 'src/hooks/useTextInput';
+import { ruleFilterText } from 'src/store/rules';
+
+import shared from '../shared.module.css';
+
+export function TextFilter() {
+ const [onChange, text] = useTextInut(ruleFilterText);
+ return (
+ <input
+ className={shared.input}
+ type="text"
+ value={text}
+ onChange={onChange}
+ placeholder="Filter"
+ />
+ );
+}