From 89d100c5484d5702ebe4a077f23509255484413a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=99=90UCW?= <45730483+wuxianucw@users.noreply.github.com> Date: Tue, 24 Aug 2021 00:00:28 +0800 Subject: [PATCH] ui: new autocomplete components (#190) --- packages/ui-default/.eslintrc.js | 4 + .../autocomplete/DomainSelectAutoComplete.js | 44 --- .../autocomplete/DomainSelectAutoComplete.jsx | 35 +++ .../autocomplete/ProblemSelectAutoComplete.js | 41 --- .../ProblemSelectAutoComplete.jsx | 35 +++ .../autocomplete/UserSelectAutoComplete.js | 44 --- .../autocomplete/UserSelectAutoComplete.jsx | 35 +++ .../autocomplete/autocomplete.page.styl | 119 +++++++- .../autocomplete/components/AutoComplete.jsx | 270 ++++++++++++++++++ .../components/DomainSelectAutoComplete.jsx | 65 +++++ .../components/ProblemSelectAutoComplete.jsx | 62 ++++ .../components/UserSelectAutoComplete.jsx | 60 ++++ .../components/autocomplete/index.js | 198 ------------- .../components/autocomplete/index.jsx | 78 +++++ .../problemselectautocomplete.page.styl | 2 +- .../ui-default/templates/contest_edit.html | 166 +++++------ 16 files changed, 839 insertions(+), 419 deletions(-) delete mode 100644 packages/ui-default/components/autocomplete/DomainSelectAutoComplete.js create mode 100644 packages/ui-default/components/autocomplete/DomainSelectAutoComplete.jsx delete mode 100644 packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.js create mode 100644 packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.jsx delete mode 100644 packages/ui-default/components/autocomplete/UserSelectAutoComplete.js create mode 100644 packages/ui-default/components/autocomplete/UserSelectAutoComplete.jsx create mode 100644 packages/ui-default/components/autocomplete/components/AutoComplete.jsx create mode 100644 packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.jsx create mode 100644 packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.jsx create mode 100644 packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.jsx delete mode 100644 packages/ui-default/components/autocomplete/index.js create mode 100644 packages/ui-default/components/autocomplete/index.jsx diff --git a/packages/ui-default/.eslintrc.js b/packages/ui-default/.eslintrc.js index a99a7c335..0a3c191c8 100644 --- a/packages/ui-default/.eslintrc.js +++ b/packages/ui-default/.eslintrc.js @@ -55,6 +55,10 @@ module.exports = { // FIXME A bug with eslint-parser 'template-curly-spacing': 'off', + // Note: must disable the base rule as it can report incorrect errors + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': ['error'], + 'comma-dangle': [ 'error', 'always-multiline', diff --git a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.js b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.js deleted file mode 100644 index 3bc39f6bf..000000000 --- a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.js +++ /dev/null @@ -1,44 +0,0 @@ -import { assign } from 'lodash'; -import tpl from 'vj/utils/tpl'; -import request from 'vj/utils/request'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; -import AutoComplete from '.'; - -function getText(domain) { - return domain._id; -} - -function getItems(val) { - return request.get('/domain/search', { q: val }); -} - -function renderItem(domain) { - return tpl` -
-
- -
-
-
${domain.name}
-
ID = ${domain._id}
-
-
- `; -} - -export default class DomainSelectAutoComplete extends AutoComplete { - static DOMAttachKey = 'vjDomainSelectAutoCompleteInstance'; - - constructor($dom, options) { - super($dom, { - classes: 'domain-select', - items: getItems, - render: renderItem, - text: getText, - ...options, - }); - } -} - -assign(DomainSelectAutoComplete, DOMAttachedObject); -window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.jsx b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.jsx new file mode 100644 index 000000000..ffab53bad --- /dev/null +++ b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { assign } from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import AutoComplete from '.'; +import DomainSelectAutoCompleteFC from './components/DomainSelectAutoComplete'; + +export default class DomainSelectAutoComplete extends AutoComplete { + static DOMAttachKey = 'ucwDomainSelectAutoCompleteInstance'; + + constructor($dom, options) { + super($dom, { + classes: 'domain-select', + ...options, + }); + } + + attach() { + const value = this.$dom.val(); + ReactDOM.render( + { this.ref = ref; }} + height="34px" + defaultItems={value} + onChange={this.onChange} + multi={this.options.multi} + freeSolo={this.options.multi} + />, + this.container, + ); + } +} + +assign(DomainSelectAutoComplete, DOMAttachedObject); +window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.js b/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.js deleted file mode 100644 index 7d755c8fe..000000000 --- a/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.js +++ /dev/null @@ -1,41 +0,0 @@ -import { assign } from 'lodash'; -import tpl from 'vj/utils/tpl'; -import request from 'vj/utils/request'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; -import AutoComplete from './index'; - -function getText(pdoc) { - return pdoc.docId; -} - -function getItems(prefix) { - return request.get(`/d/${UiContext.domainId}/problem/list`, { prefix }); -} - -function renderItem(pdoc) { - return tpl` -
-
-
${pdoc.pid ? `${pdoc.pid} ` : ''}${pdoc.title}
-
ID = ${pdoc.docId}
-
-
- `; -} - -export default class ProblemSelectAutoComplete extends AutoComplete { - static DOMAttachKey = 'vjProblemSelectAutoCompleteInstance'; - - constructor($dom, options) { - super($dom, { - classes: 'problem-select', - items: getItems, - render: renderItem, - text: getText, - ...options, - }); - } -} - -assign(ProblemSelectAutoComplete, DOMAttachedObject); -window.Hydro.components.ProblemSelectAutoComplete = ProblemSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.jsx b/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.jsx new file mode 100644 index 000000000..13d6572ce --- /dev/null +++ b/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { assign } from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import AutoComplete from '.'; +import ProblemSelectAutoCompleteFC from './components/ProblemSelectAutoComplete'; + +export default class ProblemSelectAutoComplete extends AutoComplete { + static DOMAttachKey = 'ucwProblemSelectAutoCompleteInstance'; + + constructor($dom, options) { + super($dom, { + classes: 'problem-select', + ...options, + }); + } + + attach() { + const value = this.$dom.val(); + ReactDOM.render( + { this.ref = ref; }} + height="34px" + defaultItems={value} + onChange={this.onChange} + multi={this.options.multi} + freeSolo={this.options.multi} + />, + this.container, + ); + } +} + +assign(ProblemSelectAutoComplete, DOMAttachedObject); +window.Hydro.components.ProblemSelectAutoComplete = ProblemSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.js b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.js deleted file mode 100644 index 958e89305..000000000 --- a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.js +++ /dev/null @@ -1,44 +0,0 @@ -import _ from 'lodash'; -import tpl from 'vj/utils/tpl'; -import request from 'vj/utils/request'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; -import AutoComplete from './index'; - -function getText(user) { - return user.uname; -} - -function getItems(val) { - return request.get('/user/search', { q: val }); -} - -function renderItem(user) { - return tpl` -
-
- -
-
-
${user.uname}
-
UID = ${user._id}
-
-
- `; -} - -export default class UserSelectAutoComplete extends AutoComplete { - static DOMAttachKey = 'vjUserSelectAutoCompleteInstance'; - - constructor($dom, options) { - super($dom, { - classes: 'user-select', - items: getItems, - render: renderItem, - text: getText, - ...options, - }); - } -} - -_.assign(UserSelectAutoComplete, DOMAttachedObject); -window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.jsx b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.jsx new file mode 100644 index 000000000..62d493e35 --- /dev/null +++ b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { assign } from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import AutoComplete from '.'; +import UserSelectAutoCompleteFC from './components/UserSelectAutoComplete'; + +export default class UserSelectAutoComplete extends AutoComplete { + static DOMAttachKey = 'ucwUserSelectAutoCompleteInstance'; + + constructor($dom, options) { + super($dom, { + classes: 'user-select', + ...options, + }); + } + + attach() { + const value = this.$dom.val(); + ReactDOM.render( + { this.ref = ref; }} + height="34px" + defaultItems={value} + onChange={this.onChange} + multi={this.options.multi} + freeSolo={this.options.multi} + />, + this.container, + ); + } +} + +assign(UserSelectAutoComplete, DOMAttachedObject); +window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/autocomplete.page.styl b/packages/ui-default/components/autocomplete/autocomplete.page.styl index cd631147f..676ca92cd 100644 --- a/packages/ui-default/components/autocomplete/autocomplete.page.styl +++ b/packages/ui-default/components/autocomplete/autocomplete.page.styl @@ -1,11 +1,112 @@ -.drop-element.autocomplete - max-width: rem(250px) +.autocomplete-dummy + display: none !important - .empty-row - color: $autocomplete-empty-row-color - font-size: $(font-size-small) - padding: rem(10px) +.autocomplete-wrapper + width: 300px + border: 1px solid #d9d9d9 + background-color: #fff + padding: 1px + display: flex + flex-wrap: wrap + overflow-y: auto - .menu - max-height: rem(250px) - overflow: auto + &:hover + border-color: #40a9ff + + &.focused + border-color: #40a9ff + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2) + + input + background-color: #fff + color: rgba(0,0,0,.85) + height: 30px + box-sizing: border-box + padding: 4px 6px + width: 0 + min-width: 30px + flex-grow: 1 + border: 0 + margin: 0 + outline: 0 + +.autocomplete-tag + display: flex + align-items: center + height: 24px + margin: 2px + line-height: 22px + background-color: #fafafa + border: 1px solid #e8e8e8 + border-radius: 2px + box-sizing: content-box + padding: 0 4px 0 10px + outline: 0 + overflow: hidden + + &:focus + border-color: #40a9ff + background-color: #e6f7ff + + &>div + overflow: hidden + white-space: nowrap + text-overflow: ellipsis + + &>svg + font-size: 12px + cursor: pointer + padding: 4px + +.autocomplete-list + min-width: 300px + margin: 2px 0 0 + padding: 0 + position: absolute + list-style: none + background-color: #fff + overflow: auto + max-height: 500px + border-radius: 4px + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) + z-index: 1 + + li + padding: 5px 12px + display: flex + + &>div + flex-grow: 1 + + &>svg + color: transparent + + &[aria-selected='true'] + background-color: #fafafa + font-weight: 600 + + svg + color: #1890ff + + &[data-focus='true'] + background-color: #e6f7ff + cursor: pointer + + svg + color: currentColor + +svg.autocomplete-icon + -webkit-user-select: none + -moz-user-select: none + -ms-user-select: none + user-select: none + width: 1em + height: 1em + display: inline-block + fill: currentColor + -webkit-flex-shrink: 0 + -ms-flex-negative: 0 + flex-shrink: 0 + -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms + transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms + font-size: 1.25rem diff --git a/packages/ui-default/components/autocomplete/components/AutoComplete.jsx b/packages/ui-default/components/autocomplete/components/AutoComplete.jsx new file mode 100644 index 000000000..296d5227a --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/AutoComplete.jsx @@ -0,0 +1,270 @@ +/* eslint-disable jsx-a11y/role-supports-aria-props */ +/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ +import React, { + forwardRef, useState, useRef, useImperativeHandle, createRef, useEffect, +} from 'react'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; +import { Icon } from '@blueprintjs/core'; + +// eslint-disable-next-line prefer-arrow-callback +const AutoComplete = forwardRef(function AutoComplete(props, ref) { + const width = props.width ?? '100%'; + // if you need fix height, set to at least "30px" + // for Hydro, no less then "34px" can be better + const height = props.height ?? 'auto'; + const listStyle = props.listStyle ?? {}; + const itemsFn = props.itemsFn ?? (async () => []); + const renderItem = props.renderItem ?? ((item) => item); + const itemText = props.itemText ?? ((item) => item); + const itemKey = props.itemKey ?? itemText; + const onChange = props.onChange ?? (() => { }); + const multi = props.multi ?? false; + const rawDefaultItems = props.defaultItems ?? []; + const defaultItems = typeof rawDefaultItems === 'string' + ? props.defaultItems.split(',').map((i) => i.trim()).filter((i) => i.length > 0) : rawDefaultItems; + const allowEmptyQuery = props.allowEmptyQuery ?? false; + const freeSolo = props.freeSolo ?? false; + const freeSoloConverter = freeSolo ? props.freeSoloConverter ?? ((i) => i) : ((i) => i); + + const [focused, setFocused] = useState(false); // is focused + const [selected, setSelected] = useState(defaultItems); // selected items + const [selectedKeys, setSelectedKeys] = useState(defaultItems.map((i) => itemKey(i))); // keys of selected items + const [itemList, setItemList] = useState([]); // items list + const [currentItem, setCurrentItem] = useState(null); // index of current item (in item list) + + const inputRef = createRef(); + const listRef = createRef(); + + const cache = useRef({}); + + const queryList = async (query) => { + if (!query && !allowEmptyQuery) { + setItemList([]); + setCurrentItem(null); + return; + } + let items; + if (cache.current[query]) { + items = cache.current[query]; + } else { + items = await itemsFn(query); + cache.current[query] = items; + } + setItemList(items); + setCurrentItem(null); + }; + + const calculateValue = () => { + const query = inputRef.current?.value; + if (!query) return multi ? selectedKeys.join(',') : ''; + return (multi && selectedKeys.length > 0) ? `${selectedKeys.join(',')},${query}` : query; + }; + + const dispatchChange = () => { + const value = calculateValue(); + onChange(value); + }; + + useEffect(() => { + dispatchChange(); + }, [selectedKeys, multi]); + + const handleInputChange = debounce(async (e) => { + const { target } = e; + const { value } = target; + queryList(value); + }, 300); + + const toggleItem = (item) => { + if (multi) { + const key = itemKey(item); + const idx = selectedKeys.indexOf(key); + if (idx !== -1) { + setSelected((s) => { + const newSelected = [...s]; + newSelected.splice(idx, 1); + return newSelected; + }); + setSelectedKeys((s) => { + const newSelectedKeys = [...s]; + newSelectedKeys.splice(idx, 1); + return newSelectedKeys; + }); + } else { + setSelected((s) => [...s, item]); + setSelectedKeys((s) => [...s, key]); + } + inputRef.current.value = ''; + inputRef.current.focus(); + } else { + inputRef.current.value = itemKey(item); + } + setItemList([]); + setCurrentItem(null); + }; + + const handleInputKeyDown = (e) => { + const { key, target } = e; + if (key === 'Escape') { + setItemList([]); + setCurrentItem(null); + return; + } + if (key === 'Enter' || key === ',') { + e.preventDefault(); + if (currentItem !== null) { + toggleItem(itemList[currentItem]); + return; + } + if (freeSolo && target.value !== '') { + toggleItem(freeSoloConverter(target.value)); + } + return; + } + if (key === 'Backspace') { + if (target.value.length > 0) return; + if (selected.length > 0) { + setSelected((s) => s.slice(0, -1)); + setSelectedKeys((s) => s.slice(0, -1)); + } + return; + } + if (key === 'ArrowUp') { + e.preventDefault(); + if (itemList.length === 0) return; + const idx = (currentItem ?? 0) - 1; + const newIdx = idx < 0 ? itemList.length - 1 : idx; + setCurrentItem(newIdx); + listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + return; + } + if (key === 'ArrowDown') { + e.preventDefault(); + if (itemList.length === 0) return; + const idx = (currentItem ?? itemList.length - 1) + 1; + const newIdx = idx >= itemList.length ? 0 : idx; + setCurrentItem(newIdx); + listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + // eslint-disable-next-line no-useless-return + return; + } + // TODO: handle other keys + }; + + useImperativeHandle(ref, () => ({ + getSelectedItems: () => selected, + getSelectedItemsAsString: () => selectedKeys.join(','), + setSelectedItems: (items) => { + setSelected(items); + setSelectedKeys(items.map((i) => itemKey(i))); + }, + getQuery: () => inputRef.current?.value, + setQuery: (query) => { + if (inputRef.current) inputRef.current.value = query; + }, + triggerQuery: () => queryList(inputRef.current?.value), + closeList: () => { + setItemList([]); + setCurrentItem(null); + }, + getValue: () => (multi ? selectedKeys.join(',') : (inputRef.current.value ?? '')), + getValueArray: () => (multi ? selected : [inputRef.current?.value].filter((i) => !!i)), + getValueWithQuery: () => calculateValue(), + getValueArrayWithQuery: () => { + const query = inputRef.current?.value; + if (!query) return multi ? selected : ['']; + return multi ? [...selected, query] : [query]; + }, + clear: () => { + setSelected([]); + setSelectedKeys([]); + if (inputRef.current) inputRef.current.value = ''; + }, + focus: () => { + setFocused(true); + inputRef.current?.focus(); + }, + })); + + return ( +
+
+ {multi && selected.map((item, idx) => ( +
+
{itemText(item)}
+ toggleItem(item)} /> +
+ ))} + { + dispatchChange(); + handleInputChange(e); + }} + onFocus={() => { + setFocused(true); + if (allowEmptyQuery) { + handleInputChange(); + } + }} + onBlur={() => setFocused(false)} + onKeyDown={handleInputKeyDown} + /> +
+ {focused && itemList.length > 0 && ( +
    e.preventDefault()}> + {itemList.map((item, idx) => ( +
  • toggleItem(item)} + onMouseMove={() => setCurrentItem(idx)} + aria-selected={selectedKeys.includes(itemKey(item))} + data-focus={idx === currentItem} + > +
    {renderItem(item)}
    + +
  • + ))} +
+ )} +
+ ); +}); + +AutoComplete.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + listStyle: PropTypes.object, + itemsFn: PropTypes.func.isRequired, + itemKey: PropTypes.func, + renderItem: PropTypes.func, + itemText: PropTypes.func, + onChange: PropTypes.func.isRequired, + multi: PropTypes.bool, + defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]), + allowEmptyQuery: PropTypes.bool, + freeSolo: PropTypes.bool, + freeSoloConverter: PropTypes.func, +}; + +AutoComplete.defaultProps = { + width: '100%', + height: 'auto', + listStyle: {}, + renderItem: (item) => item, + itemText: (item) => item, + multi: false, + defaultItems: [], + allowEmptyQuery: false, + freeSolo: false, + freeSoloConverter: (input) => input, +}; + +AutoComplete.displayName = 'AutoComplete'; + +export default AutoComplete; diff --git a/packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.jsx b/packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.jsx new file mode 100644 index 000000000..d9ac74d85 --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/DomainSelectAutoComplete.jsx @@ -0,0 +1,65 @@ +import React, { forwardRef } from 'react'; +import PropTypes from 'prop-types'; +import request from 'vj/utils/request'; +import AutoComplete from './AutoComplete'; + +// eslint-disable-next-line prefer-arrow-callback +const DomainSelectAutoComplete = forwardRef(function DomainSelectAutoComplete(props, ref) { + const itemsFn = (query) => request.get('/domain/search', { q: query }); + + /* const itemText = (domain) => { + return domain.name; + }; */ + + const itemKey = (domain) => domain._id || domain; + + const renderItem = (domain) => ( +
+
+ +
+
+
{domain.name}
+
ID = {domain._id}
+
+
+ ); + + return ( + + ); +}); + +DomainSelectAutoComplete.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + listStyle: PropTypes.object, + onChange: PropTypes.func.isRequired, + multi: PropTypes.bool, + defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]), + allowEmptyQuery: PropTypes.bool, + freeSolo: PropTypes.bool, + freeSoloConverter: PropTypes.func, +}; + +DomainSelectAutoComplete.defaultProps = { + width: '100%', + height: 'auto', + listStyle: {}, + multi: false, + defaultItems: [], + allowEmptyQuery: false, + freeSolo: false, + freeSoloConverter: (input) => input, +}; + +DomainSelectAutoComplete.displayName = 'DomainSelectAutoComplete'; + +export default DomainSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.jsx b/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.jsx new file mode 100644 index 000000000..9d5dc5581 --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.jsx @@ -0,0 +1,62 @@ +import React, { forwardRef } from 'react'; +import PropTypes from 'prop-types'; +import request from 'vj/utils/request'; +import AutoComplete from './AutoComplete'; + +// eslint-disable-next-line prefer-arrow-callback +const ProblemSelectAutoComplete = forwardRef(function ProblemSelectAutoComplete(props, ref) { + const itemsFn = (query) => request.get(`/d/${UiContext.domainId}/problem/list`, { prefix: query }); + + /* const itemText = (pdoc) => { + return (pdoc.pid ? `${pdoc.pid} ` : '') + pdoc.title; + }; */ + + const itemKey = (pdoc) => pdoc.docId || pdoc; + + const renderItem = (pdoc) => ( +
+
+
{pdoc.pid ? `${pdoc.pid} ` : ''}{pdoc.title}
+
ID = {pdoc.docId}
+
+
+ ); + + return ( + + ); +}); + +ProblemSelectAutoComplete.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + listStyle: PropTypes.object, + onChange: PropTypes.func.isRequired, + multi: PropTypes.bool, + defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]), + allowEmptyQuery: PropTypes.bool, + freeSolo: PropTypes.bool, + freeSoloConverter: PropTypes.func, +}; + +ProblemSelectAutoComplete.defaultProps = { + width: '100%', + height: 'auto', + listStyle: {}, + multi: false, + defaultItems: [], + allowEmptyQuery: false, + freeSolo: false, + freeSoloConverter: (input) => input, +}; + +ProblemSelectAutoComplete.displayName = 'ProblemSelectAutoComplete'; + +export default ProblemSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.jsx b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.jsx new file mode 100644 index 000000000..fa83cbbca --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.jsx @@ -0,0 +1,60 @@ +import React, { forwardRef } from 'react'; +import PropTypes from 'prop-types'; +import request from 'vj/utils/request'; +import AutoComplete from './AutoComplete'; + +// eslint-disable-next-line prefer-arrow-callback +const UserSelectAutoComplete = forwardRef(function UserSelectAutoComplete(props, ref) { + const itemsFn = (query) => request.get('/user/search', { q: query }); + + const itemText = (user) => user.uname || user; + + const renderItem = (user) => ( +
+
+ +
+
+
{user.uname}
+
UID = {user._id}
+
+
+ ); + + return ( + + ); +}); + +UserSelectAutoComplete.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + listStyle: PropTypes.object, + onChange: PropTypes.func.isRequired, + multi: PropTypes.bool, + defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]), + allowEmptyQuery: PropTypes.bool, + freeSolo: PropTypes.bool, + freeSoloConverter: PropTypes.func, +}; + +UserSelectAutoComplete.defaultProps = { + width: '100%', + height: 'auto', + listStyle: {}, + multi: false, + defaultItems: [], + allowEmptyQuery: false, + freeSolo: false, + freeSoloConverter: (input) => input, +}; + +UserSelectAutoComplete.displayName = 'UserSelectAutoComplete'; + +export default UserSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/index.js b/packages/ui-default/components/autocomplete/index.js deleted file mode 100644 index 8f3a648c8..000000000 --- a/packages/ui-default/components/autocomplete/index.js +++ /dev/null @@ -1,198 +0,0 @@ -import Drop from 'tether-drop'; -import { assign } from 'lodash'; -import 'jquery-scroll-lock'; - -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; - -import i18n from 'vj/utils/i18n'; -import tpl from 'vj/utils/tpl'; -import zIndexManager from 'vj/utils/zIndexManager'; - -export default class AutoComplete extends DOMAttachedObject { - static DOMAttachKey = 'vjAutoCompleteInstance'; - - constructor($dom, options = {}) { - super($dom); - this.options = { - items: async () => [], - render: () => '', - text: () => null, - minChar: 1, - cache: true, - clearDefaultValue: true, - position: 'bottom left', - classes: '', - multi: false, - ...options, - }; - this.clear(this.options.clearDefaultValue); - this.menuShown = false; - this.cache = {}; - this.currentItems = []; - this.$menu = $(``); - this.$menu.scrollLock({ strict: false }); - this.$menu.on('mousedown', this.onMenuClick.bind(this)); - this.$menu.on('mousedown', '.menu__item', this.onItemClick.bind(this)); - this.dropInstance = new Drop({ - classes: 'autocomplete dropdown', - target: this.$dom[0], - content: this.$menu[0], - position: this.options.position, - constrainToWindow: false, - constrainToScrollParent: false, - openOn: false, - }); - this.dropInstance.on('open', this.onDropOpen.bind(this)); - this.attach(); - } - - clear(clearValue = true) { - if (clearValue) this.$dom.val(''); - this._value = null; - this.lastText = null; - } - - attach() { - this.$dom.on(`click.${this.eventNS}`, this.onClick.bind(this)); - this.$dom.on(`focus.${this.eventNS}`, this.onFocus.bind(this)); - this.$dom.on(`blur.${this.eventNS}`, this.onBlur.bind(this)); - this.$dom.on(`keydown.${this.eventNS}`, this.onKeyDown.bind(this)); - this.$dom.on(`keyup.${this.eventNS}`, this.onKeyUp.bind(this)); - } - - onClick() { - this.updateOpenState(); - } - - onFocus() { - this.isFocus = true; - this.updateOpenState(); - } - - onBlur() { - this.isFocus = false; - this.updateOpenState(); - } - - onKeyDown() { - // TODO: Implement keyboard navigation - } - - onKeyUp(ev) { - if (ev.which === 27 /* ESC */) { - this.close(); - return; - } - if (this.$dom.val() === this.lastText) return; - this.lastText = this.$dom.val(); - this.updateOpenState(); - if (this.isOpen) this.renderList(); - } - - onMenuClick(ev) { - ev.preventDefault(); // prevent from losing focus - } - - onItemClick(ev) { - const idx = $(ev.currentTarget).attr('data-idx'); - if (idx === undefined) return; - const item = this.currentItems[idx]; - const text = this.options.text(item); - this._value = item; - this.$dom.trigger('vjAutoCompleteSelect', item); - if (text != null) { - if (!this.options.multi) this.$dom.val(text); - else { - const current = this.$dom.val().replace(/,/g, ','); - if (current.indexOf(',') !== -1) { - const items = current.split(','); - items[items.length - 1] = text; - this.$dom.val(items.join(', ')); - } else this.$dom.val(`${text}, `); - } - } - this.close(); - } - - onDropOpen() { - $(this.dropInstance.drop).css('z-index', zIndexManager.getNext()); - } - - async getItems(val) { - if (this.cache[val] !== undefined) return this.cache[val]; - const data = await this.options.items(val); - if (this.options.cache) this.cache[val] = data; - return data; - } - - getHtml(items) { - if (items.length === 0) { - return tpl`
${i18n('Oops, there are no results.')}
`; - } - return items.map((item, idx) => ` - - `).join('\n'); - } - - async renderList() { - let val = this.$dom.val(); - if (this.options.multi) { - const elems = val.split(',').map((i) => i.trim()); - val = elems[elems.length - 1]; - } - const items = await this.getItems(val); - const html = this.getHtml(items); - this.currentItems = items; - this.$menu.html(html); - } - - open() { - if (this.isOpen) return; - this.dropInstance.open(); - this.isOpen = true; - } - - close() { - if (!this.isOpen) return; - this.dropInstance.close(); - this.isOpen = false; - } - - updateOpenState() { - let content = this.$dom.val(); - if (this.options.multi) { - content = content.split(','); - content = content[content.length - 1].trim(); - } - if (this.isFocus && content.length >= this.options.minChar) { - if (!this.isOpen) { - this.open(); - this.renderList(); - } - } else if (this.isOpen) this.close(); - } - - detach() { - if (this.detached) return; - super.detach(); - this.$dom.off(`focus.${this.eventNS}`); - this.$dom.off(`blur.${this.eventNS}`); - this.$dom.off(`keydown.${this.eventNS}`); - this.$dom.off(`keyup.${this.eventNS}`); - this.dropInstance.destroy(); - this.$menu.remove(); - } - - value() { - return this._value; - } - - focus() { - this.$dom.focus(); - } -} - -assign(AutoComplete, DOMAttachedObject); -window.Hydro.components.autocomplete = AutoComplete; diff --git a/packages/ui-default/components/autocomplete/index.jsx b/packages/ui-default/components/autocomplete/index.jsx new file mode 100644 index 000000000..82d78f5d8 --- /dev/null +++ b/packages/ui-default/components/autocomplete/index.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { assign } from 'lodash'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import AutoCompleteFC from './components/AutoComplete'; + +export default class AutoComplete extends DOMAttachedObject { + static DOMAttachKey = 'ucwAutoCompleteInstance'; + + constructor($dom, options = {}) { + super($dom); + this.options = { + items: async () => [], + render: () => '', + text: () => null, + multi: false, + ...options, + }; + this.ref = null; + this.onChange = this.onChange.bind(this); + this.container = document.createElement('div'); + this.$dom.addClass('autocomplete-dummy').after(this.container); + setTimeout(() => this.attach(), 0); + } + + clear(clearValue = true) { + if (!this.ref) return; + if (clearValue) this.ref.clear(); + else this.ref.closeList(); + } + + onChange(val) { + this.$dom.val(val); + } + + attach() { + const value = this.$dom.val(); + ReactDOM.render( + { this.ref = ref; }} + height="34px" + itemsFn={this.options.items} + renderItem={this.options.render} + itemText={this.options.text} + defaultItems={value} + onChange={this.onChange} + multi={this.options.multi} + freeSolo={this.options.multi} + />, + this.container, + ); + } + + open() { + if (!this.ref) return; + this.ref.triggerQuery(); + } + + close() { + if (!this.ref) return; + this.ref.closeList(); + } + + detach() { + if (this.detached) return; + super.detach(); + ReactDOM.unmountComponentAtNode(this.container); + this.$dom.removeClass('autocomplete-dummy'); + this.container.parentNode.removeChild(this.container); + } + + focus() { + this.ref.focus(); + } +} + +assign(AutoComplete, DOMAttachedObject); +window.Hydro.components.autocomplete = AutoComplete; diff --git a/packages/ui-default/components/autocomplete/problemselectautocomplete.page.styl b/packages/ui-default/components/autocomplete/problemselectautocomplete.page.styl index d7910b617..9e5e428a6 100644 --- a/packages/ui-default/components/autocomplete/problemselectautocomplete.page.styl +++ b/packages/ui-default/components/autocomplete/problemselectautocomplete.page.styl @@ -1,6 +1,6 @@ .menu.problem-select width: rem(200px) -.problem-select__uid +.problem-select__id font-size: rem($font-size-small) color: $userselect-uid-color diff --git a/packages/ui-default/templates/contest_edit.html b/packages/ui-default/templates/contest_edit.html index c011aee2b..c3b07ea43 100644 --- a/packages/ui-default/templates/contest_edit.html +++ b/packages/ui-default/templates/contest_edit.html @@ -7,100 +7,102 @@
{{ form.form_select({ - columns:3, - label:'Rule', - name:'rule', - options:rules, - value:tdoc['rule']|default(''), - row:false - }) }} + columns:3, + label:'Rule', + name:'rule', + options:rules, + value:tdoc['rule']|default(''), + row:false + }) }} {{ form.form_text({ - columns:4, - label:'Title', - name:'title', - placeholder:_('title'), - value:tdoc.title|default(''), - autofocus:true, - row:false - }) }} + columns:4, + label:'Title', + name:'title', + placeholder:_('title'), + value:tdoc.title|default(''), + autofocus:true, + row:false + }) }} {{ form.form_text({ - columns:3, - label:'Invitation Code', - name:'code', - placeholder:_('(leave blank if none)'), - value:tdoc._code|default(''), - row:false - }) }} + columns:3, + label:'Invitation Code', + name:'code', + placeholder:_('(leave blank if none)'), + value:tdoc._code|default(''), + row:false + }) }} {{ form.form_checkbox({ - columns:2, - label:'Rated', - name:'rated', - placeholder:_('Is rated'), - value:tdoc.rated|default(true), - row:false - }) }} -
+ columns:2, + label:'Rated', + name:'rated', + placeholder:_('Is rated'), + value:tdoc.rated|default(true), + row:false + }) }} +
+
{{ form.form_text({ - columns:4, - label:'Begin Date', - name:'beginAtDate', - placeholder:'YYYY-mm-dd', - value:date_text, - date:true, - row:false - }) }} + columns:4, + label:'Begin Date', + name:'beginAtDate', + placeholder:'YYYY-mm-dd', + value:date_text, + date:true, + row:false + }) }} {{ form.form_text({ - columns:4, - label:'Begin Time', - name:'beginAtTime', - placeholder:'HH:MM', - value:time_text, - time:true, - row:false - }) }} + columns:4, + label:'Begin Time', + name:'beginAtTime', + placeholder:'HH:MM', + value:time_text, + time:true, + row:false + }) }} {{ form.form_text({ - columns:4, - label:'Duration (hours)', - name:'duration', - value:duration|default('3'), - row:false - }) }} + columns:4, + label:'Duration (hours)', + name:'duration', + value:duration|default('3'), + row:false + }) }}
{{ form.form_text({ - columns:null, - label:'Problems', - name:'pids', - value:pids, - noAutocomplete:true, - placeholder:_("Seperated with ','") - }) }} + columns:null, + label:'Problems', + name:'pids', + value:pids, + placeholder:_("Seperated with ','") + }) }} {{ form.form_textarea({ - columns:null, - label:'Content', - name:'content', - value:tdoc['content']|default(''), - markdown:true - }) }} -
- - {% if page_name == 'contest_edit' %} - - {% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %} + columns:null, + label:'Content', + name:'content', + value:tdoc['content']|default(''), + markdown:true + }) }} +
+
+ + {% if page_name == 'contest_edit' %} + + {% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %} - {% endif %} - {% else %} - - {% endif %} - -
+ {% endif %} + {% else %} + + {% endif %} + +
+