mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui: new autocomplete components (#190)
This commit is contained in:
1 parent
9eece289e0
commit
89d100c548
16 files changed
+839
-419
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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`
|
||||
<div class="media">
|
||||
<div class="media__left medium">
|
||||
<img class="small domain-profile-avatar" src="${domain.avatarUrl}" width="30" height="30">
|
||||
</div>
|
||||
<div class="media__body medium">
|
||||
<div class="domain-select__name">${domain.name}</div>
|
||||
<div class="domain-select__id">ID = ${domain._id}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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(
|
||||
<DomainSelectAutoCompleteFC
|
||||
ref={(ref) => { 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;
|
||||
@@ -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`
|
||||
<div class="media">
|
||||
<div class="media__body medium">
|
||||
<div class="problem-select__name">${pdoc.pid ? `${pdoc.pid} ` : ''}${pdoc.title}</div>
|
||||
<div class="problem-select__id">ID = ${pdoc.docId}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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(
|
||||
<ProblemSelectAutoCompleteFC
|
||||
ref={(ref) => { 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;
|
||||
@@ -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`
|
||||
<div class="media">
|
||||
<div class="media__left medium">
|
||||
<img class="small user-profile-avatar" src="${user.avatarUrl}" width="30" height="30">
|
||||
</div>
|
||||
<div class="media__body medium">
|
||||
<div class="user-select__uname">${user.uname}</div>
|
||||
<div class="user-select__uid">UID = ${user._id}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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(
|
||||
<UserSelectAutoCompleteFC
|
||||
ref={(ref) => { 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;
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
<div style={{ display: 'inline-block', width: '100%' }}>
|
||||
<div
|
||||
className={focused ? 'autocomplete-wrapper focused' : 'autocomplete-wrapper'}
|
||||
style={{ width, height }}
|
||||
>
|
||||
{multi && selected.map((item, idx) => (
|
||||
<div className="autocomplete-tag" key={selectedKeys[idx]}>
|
||||
<div>{itemText(item)}</div>
|
||||
<Icon icon="cross" onClick={() => toggleItem(item)} />
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
dispatchChange();
|
||||
handleInputChange(e);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
if (allowEmptyQuery) {
|
||||
handleInputChange();
|
||||
}
|
||||
}}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{focused && itemList.length > 0 && (
|
||||
<ul ref={listRef} className="autocomplete-list" style={listStyle} onMouseDown={(e) => e.preventDefault()}>
|
||||
{itemList.map((item, idx) => (
|
||||
<li
|
||||
key={itemKey(item)}
|
||||
onClick={() => toggleItem(item)}
|
||||
onMouseMove={() => setCurrentItem(idx)}
|
||||
aria-selected={selectedKeys.includes(itemKey(item))}
|
||||
data-focus={idx === currentItem}
|
||||
>
|
||||
<div>{renderItem(item)}</div>
|
||||
<Icon icon="tick" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -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) => (
|
||||
<div className="media">
|
||||
<div className="media__left medium">
|
||||
<img className="small domain-profile-avatar" src={domain.avatarUrl} alt="" width="30" height="30" />
|
||||
</div>
|
||||
<div className="media__body medium">
|
||||
<div className="domain-select__name">{domain.name}</div>
|
||||
<div className="domain-select__id">ID = {domain._id}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
ref={ref}
|
||||
itemsFn={itemsFn}
|
||||
itemText={itemKey}
|
||||
itemKey={itemKey}
|
||||
renderItem={renderItem}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -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) => (
|
||||
<div className="media">
|
||||
<div className="media__body medium">
|
||||
<div className="problem-select__name">{pdoc.pid ? `${pdoc.pid} ` : ''}{pdoc.title}</div>
|
||||
<div className="problem-select__id">ID = {pdoc.docId}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
ref={ref}
|
||||
itemsFn={itemsFn}
|
||||
itemText={itemKey}
|
||||
itemKey={itemKey}
|
||||
renderItem={renderItem}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -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) => (
|
||||
<div className="media">
|
||||
<div className="media__left medium">
|
||||
<img className="small user-profile-avatar" alt="" src={user.avatarUrl} width="30" height="30" />
|
||||
</div>
|
||||
<div className="media__body medium">
|
||||
<div className="user-select__uname">{user.uname}</div>
|
||||
<div className="user-select__uid">UID = {user._id}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
ref={ref}
|
||||
itemsFn={itemsFn}
|
||||
itemText={itemText}
|
||||
renderItem={renderItem}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -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 = $(`<ol class="menu ${this.options.classes}"></ol>`);
|
||||
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`<div class="empty-row">${i18n('Oops, there are no results.')}</div>`;
|
||||
}
|
||||
return items.map((item, idx) => `
|
||||
<li class="menu__item" data-idx="${idx}"><a href="javascript:;" class="menu__link">
|
||||
${this.options.render(item)}
|
||||
</a></li>
|
||||
`).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;
|
||||
@@ -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(
|
||||
<AutoCompleteFC
|
||||
ref={(ref) => { 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;
|
||||
@@ -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
|
||||
@@ -7,100 +7,102 @@
|
||||
<form method="post">
|
||||
<div class="row">
|
||||
{{ 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
|
||||
}) }}
|
||||
</div><div class="row">
|
||||
columns:2,
|
||||
label:'Rated',
|
||||
name:'rated',
|
||||
placeholder:_('Is rated'),
|
||||
value:tdoc.rated|default(true),
|
||||
row:false
|
||||
}) }}
|
||||
</div>
|
||||
<div class="row">
|
||||
{{ 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
|
||||
}) }}
|
||||
</div>
|
||||
{{ 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
|
||||
}) }}
|
||||
<div class="row"><div class="columns">
|
||||
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
|
||||
{% if page_name == 'contest_edit' %}
|
||||
<button type="submit" class="rounded primary button">
|
||||
{{ _('Update') }}
|
||||
</button>
|
||||
{% 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
|
||||
}) }}
|
||||
<div class="row">
|
||||
<div class="columns">
|
||||
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
|
||||
{% if page_name == 'contest_edit' %}
|
||||
<button type="submit" class="rounded primary button">
|
||||
{{ _('Update') }}
|
||||
</button>
|
||||
{% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %}
|
||||
<button name="operation" value="delete" type="submit" class="rounded button">
|
||||
{{ _('Delete') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<button type="submit" class="rounded primary button">
|
||||
{{ _('Create') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="rounded button" onclick="window.history.go(-1)">
|
||||
{{ _('Cancel') }}
|
||||
</button>
|
||||
</div></div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<button type="submit" class="rounded primary button">
|
||||
{{ _('Create') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="rounded button" onclick="window.history.go(-1)">
|
||||
{{ _('Cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user