Commit 15b21b11 by Vladislav Lagunov

Merge branch 'master' of ssh://git.bitmaster.ru:34022/npm/common

parents 3daa759d 695dd1fe
......@@ -45,6 +45,7 @@ export type Validation<A> = Partial<Record<keyof A, Error>>;
// Props
export type CardProps<T> = {
ctx?: any,
model: {
initial: T;
modified: T|null;
......@@ -55,38 +56,35 @@ export type CardProps<T> = {
// Опции для `createForm`
export type CreateFormOptions<T> = {
validate?(value: T): any;
disabled?(value: T): any;
validate?(value: T, instance): any;
disabled?(value: T, instance): any;
};
export default function createForm<O extends CreateFormOptions<any>>(options: O) {
type T = O extends CreateFormOptions<infer T> ? T : never;
function bindZoom(self: React.Component<CardProps<T>>) {
function Zoom<K1 extends keyof T, C extends Any<T[K1]>>(...keys: [K1]): (Component: C, props?: GetProps<C>) => JSX.Element;
function Zoom<K1 extends keyof T, K2 extends keyof T[K1], C extends Any<T[K1][K2]>>(...keys: [K1, K2]): (Component: C, props?: GetProps<C>) => JSX.Element;
function Zoom<K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], C extends Any<T[K1][K2][K3]>>(...keys: [K1, K2, K3]): (Component: C, props?: GetProps<C>) => JSX.Element;
function Zoom(...keys) {
return (Component, props={}) => {
const { dispatch } = self.props;
const value = self.props.model.modified || self.props.model.initial;
const error = options.validate ? options.validate(value) : {};
const disabled = options.disabled ? options.disabled(value) : {};
const onValueChange = (value, at: ObjectPath=[]) => dispatch({ tag: 'Change', value, at });
const props_ = {
...props,
value: zoomAny(keys, value),
error: zoomAny(keys, error),
disabled: zoomAny(keys, disabled),
onValueChange: zoomOnChange(keys, onValueChange),
};
return React.createElement(Component, props_);
function createZoom(self: React.Component<CardProps<T>>) {
function zoom(): FieldProps<T>;
function zoom<K1 extends keyof T>(...keys: [K1]): FieldProps<T[K1]>;
function zoom<K1 extends keyof T, K2 extends keyof T[K1]>(...keys: [K1, K2]): FieldProps<T[K1][K2]>;
function zoom<K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2]>(...keys: [K1, K2, K3]): FieldProps<T[K1][K2][K3]>;
function zoom(...keys) {
const { dispatch, ctx } = self.props;
const value = self.props.model.modified || self.props.model.initial;
const error = options.validate ? options.validate(value, self) : {};
const disabled = options.disabled ? options.disabled(value, self) : {};
const onValueChange = (value, at: ObjectPath=[]) => dispatch({ tag: 'Change', value, at });
return {
ctx,
value: zoomAny(keys, value),
error: zoomAny(keys, error),
disabled: zoomAny(keys, disabled),
onValueChange: zoomOnChange(keys, onValueChange),
};
}
return Zoom;
return zoom;
}
function set<M extends CardProps<T>['model']>(model: M, form: T): M {
......@@ -104,7 +102,7 @@ export default function createForm<O extends CreateFormOptions<any>>(options: O)
}
// @ts-ignore
return { ...options, bindZoom, set, get, modify } as { bindZoom: typeof bindZoom, set: typeof set, get: typeof get, modify: typeof modify } & O;
return { ...options, createZoom, set, get, modify } as { createZoom: typeof createZoom, set: typeof set, get: typeof get, modify: typeof modify } & O;
}
......
......@@ -5,7 +5,7 @@ import { AuthCtx as Ctx } from '~/context';
import { FieldProps } from '~/create-form';
import TextField from '~/fields/TextField';
import { Props as TextFieldProps } from '~/fields/TextField';
import { StandardProps } from '@material-ui/core';
import { StandardProps, ListItem } from '@material-ui/core';
import Icon from './Icon';
import withStyles, { StyleRules } from '@material-ui/core/styles/withStyles';
import { Theme } from '@material-ui/core/styles/createMuiTheme';
......@@ -15,10 +15,12 @@ import * as Rx from 'rxjs';
import memoize from '~/functions/memoize';
import { pick } from 'lodash';
import classNames = require('classnames');
import { withGettext, Gettext } from '~/gettext';
import { fade } from '@material-ui/core/styles/colorManipulator';
// Props
export type Props<A> = StandardProps<React.HTMLProps<HTMLDivElement>, string, 'value'> & FieldProps<A> & {
export type Props<A> = StandardProps<React.HTMLProps<HTMLDivElement>, string, 'value'|'disabled'> & FieldProps<A> & {
ctx?: Ctx;
source: Source<A>;
debounce?: number;
......@@ -48,19 +50,23 @@ export type State<A> = AutoCompleteState<A> & {
// Component
// @ts-ignore Хак для работы дженерик-параметров
@withStyles(styles)
export default class AutoComplete<A=string> extends React.Component<Props<A>, State<A>> {
// @ts-ignore
@withGettext(require('./i18n'))
export default class AutoComplete<A=string> extends React.Component<Props<A> & { __: Gettext }, State<A>> {
static defaultProps = {
openOnFocus: true,
openOnClick: true,
fullWidth: true,
printItem: x => x ? String(x) : '',
} as any;
state: State<A> = { ...ST.init(), value: null };
anchorEl: HTMLElement|null; // Ссылка на <input/>
rectEl: HTMLElement|null; // Ссылка на элемент для выравнивания, может быть тем же что и `inputEl`
debounceTimer: number|null = null;
subscription: Rx.Subscription|null = null;
paperPrevScroll: number|null = null;
paperEl: HTMLDivElement|null = null;
// Выполнение действий из ST
dispatch = (action: ST.Action<A>) => {
......@@ -144,6 +150,14 @@ export default class AutoComplete<A=string> extends React.Component<Props<A>, St
this.anchorEl = ReactDOM.findDOMNode(this) as HTMLElement;;
}
componentDidUpdate() {
if (this.paperEl && this.paperPrevScroll && this.paperEl.scrollTop !== this.paperPrevScroll) {
this.paperEl.scrollTo(0, this.paperPrevScroll);
this.paperEl = null;
this.paperPrevScroll = null;
}
}
componentWillReceiveProps(nextProps: Props<A>) {
if (nextProps.observable !== this.props.observable) this.listen(nextProps);
}
......@@ -163,13 +177,13 @@ export default class AutoComplete<A=string> extends React.Component<Props<A>, St
this.subscription && (this.subscription.unsubscribe(), this.subscription = null);
}
endAdornment = memoize((nonNull: boolean, disabled: boolean) => <React.Fragment>
endAdornment = memoize((nonNull: boolean, disabled: boolean, __: Gettext) => <React.Fragment>
<Icon onClick={disabled ? undefined : this.handleToggleVisibility}>arrow_drop_down</Icon>
{!nonNull && <Icon onClick={disabled ? undefined : this.handleCloseClick}>close</Icon>}
{!nonNull && <Icon onClick={disabled ? undefined : this.handleCloseClick} data-rh={__('Clear')}>close</Icon>}
</React.Fragment>);
childrenProps = () => {
const { nonNull, disabled } = this.props;
const { nonNull, disabled, __ } = this.props;
return {
...pick(this.props, 'disabled', 'error', 'fullWidth', 'placeholder', 'ctx'),
......@@ -180,13 +194,31 @@ export default class AutoComplete<A=string> extends React.Component<Props<A>, St
onBlur: disabled ? undefined : this.handleBlur,
onKeyDown: disabled ? undefined : this.handleKeyDown,
onClick: disabled ? undefined : this.handleClick,
endAdornment: this.endAdornment(!!nonNull, !!disabled),
endAdornment: this.endAdornment(!!nonNull, !!disabled, __),
};
};
handleSeeMore = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
// const { query: { offset, limit } } = this.state;
this.paperEl = e.currentTarget.parentElement!.parentElement! as HTMLDivElement;
this.paperPrevScroll = this.paperEl.scrollTop;
this.dispatch({ tag: 'More' });
};
renderSeeMoreLink() {
const { __, classes } = this.props;
const { total, query: { limit, offset } } = this.state;
if (total !== 0 && limit + offset >= total) return null;
return <ListItem className={classes!.seeMore}>
<a href="javascript://void 0" onMouseDown={this.handleSeeMore}>{__('See more')}</a>
</ListItem>;
}
render() {
const { className, fullWidth, children, ctx, source, debounce, renderItem, printItem, openOnFocus, openOnClick, suggestionProps, keepOpenAfterSelect, textFieldProps, classes, anchorEl, observable, ...rest } = this.props;
const { suggestions, open } = this.state;
const { suggestions, open, pending } = this.state;
const rootClass = classNames(classes!.root, className, {
[classes!.fullWidth!]: fullWidth
});
......@@ -195,6 +227,7 @@ export default class AutoComplete<A=string> extends React.Component<Props<A>, St
{!observable && (children ? React.cloneElement(children, this.childrenProps()) : <TextField {...this.childrenProps() as any} {...textFieldProps}/>)}
<Suggestions
{...suggestionProps}
pending={pending}
ctx={ctx!}
anchorEl={anchorEl || this.anchorEl || undefined}
open={open}
......@@ -202,6 +235,7 @@ export default class AutoComplete<A=string> extends React.Component<Props<A>, St
renderSuggestion={renderItem || printItem}
onSelect={this.handleSuggestionSelect}
onVisibilityChange={this.handleVisibilityChnage}
after={this.renderSeeMoreLink()}
/>
</div>;
}
......@@ -268,10 +302,20 @@ export class CreateObservable extends React.Component<CreateObservableProps> {
// Style
export function styles(theme: Theme): StyleRules {
// const { unit } = theme.spacing;
const { unit } = theme.spacing;
const linkStyles = {
color: theme.palette.primary.main,
textDecoration: 'none',
'&:hover': {
color: fade(theme.palette.primary.main, 0.75),
},
'&:active': {
color: theme.palette.error.main,
},
};
return {
root: {
root: {
'& > div': {
position: 'relative',
}
......@@ -280,5 +324,16 @@ export function styles(theme: Theme): StyleRules {
fullWidth: {
width: '100%',
},
seeMore: {
marginBottom: -unit,
'& a': {
display: 'block',
width: '100%',
height: '100%',
textAlign: 'center',
...linkStyles,
},
},
};
}
......@@ -41,7 +41,7 @@ export interface AutoCompleteQuery {
limit: number;
search: string
}
export const defaultQuery = { offset: 0, search: '', limit: 10 };
export const defaultQuery = { offset: 0, search: '', limit: 20 };
// Источник для автодополнений
......@@ -68,6 +68,7 @@ export type Action<A> =
| { tag: 'Error', error: Err }
| { tag: 'Search', value: string }
| { tag: 'Offset', value: number }
| { tag: 'More' }
| { tag: 'Query', query: AutoCompleteQuery }
| { tag: 'Query/success', suggestions: A[], total: number, query: AutoCompleteQuery }
| { tag: 'Focus' }
......@@ -106,7 +107,7 @@ export function update<A>(ctx: Ctx<A>, action: Action<A>, state: State<A>): [Sta
case 'Query': {
const { query } = action;
const { options: { source } } = ctx;
const printItem = ctx.options.printItem || String;
const printItem = ctx.options.printItem || (x => JSON.stringify(x));
if (Array.isArray(source)) {
// Поиск подходящих записей в массиве
......@@ -121,7 +122,7 @@ export function update<A>(ctx: Ctx<A>, action: Action<A>, state: State<A>): [Sta
suggestions.push(source[i]);
}
}
return [{ ...state, suggestions, query, open: true }, noop];
return [{ ...state, suggestions, query, open: true, total: source.length }, noop];
} else if ('pageCollection' in source) {
if (!('auth' in ctx) || !ctx.auth) return [state, noop];
// Запрос на поиск записей
......@@ -146,7 +147,11 @@ export function update<A>(ctx: Ctx<A>, action: Action<A>, state: State<A>): [Sta
return [state, cmd.of<Action<A>>({ tag: 'Query', query: { ...state.query, offset: action.value } })];
}
case 'Search': {
return [state, cmd.of<Action<A>>({ tag: 'Query', query: { ...state.query, search: action.value } })];
return [state, cmd.of<Action<A>>({ tag: 'Query', query: { ...defaultQuery, search: action.value } })];
}
case 'More': {
const { query: { limit } } = state;
return [state, cmd.of<Action<A>>({ tag: 'Query', query: { ...state.query, limit: limit + 20 } })];
}
case 'Focus': {
if (ctx.options.openOnFocus && !ctx.options.openOnClick) {
......
......@@ -12,21 +12,21 @@ import { FieldProps } from '~/create-form';
// Props
export type Props<A> = StandardProps<React.HTMLProps<HTMLDivElement>, string, 'value'> & FieldProps<A> & {
source: Source<A>;
export type Props<A extends any[]> = StandardProps<React.HTMLProps<HTMLDivElement>, string, 'value'|'disabled'> & FieldProps<A> & {
source: Source<A[number]>;
fullWidth?: boolean;
textFieldProps?: Partial<TextFieldProps>;
renderItem?(a: A, selected: boolean): React.ReactNode;
renderItem?(a: A[number], selected: boolean): React.ReactNode;
placeholder?: string;
printItem?(a: A): string;
printItem?(a: A[number]): string;
id?: string; // Используется для установки `key` пропсов
isEqual?(a: A, b: A): boolean;
isEqual?(a: A[number], b: A[number]): boolean;
}
// @ts-ignore Component
@withStyles(styles)
export default class MultiSelect<A> extends React.Component<Props<A>> {
export default class MultiSelect<A extends any[]> extends React.Component<Props<A>> {
static defaultProps = {
printItem: String,
} as any;
......
......@@ -7,7 +7,7 @@ import Modal from '@material-ui/core/Modal';
import Paper from '@material-ui/core/Paper';
import withStyles, { WithStyles, StyleRules } from '@material-ui/core/styles/withStyles';
import { StandardProps } from '@material-ui/core';
import PendingOverlay from '@bitmaster/components/PendingOverlay';
import PendingOverlay from '~/PendingOverlay';
import { LocaleCtx, Gettext, withGettext } from '~/gettext';
......@@ -22,6 +22,8 @@ export type Props<A=any> = StandardProps<React.HTMLProps<HTMLDivElement>, string
marginThreshold?: number;
pending?: boolean;
dontMove?: boolean;
before?: React.ReactNode;
after?: React.ReactNode;
// Callbacks
onSelect?(a: A): void;
......@@ -159,9 +161,10 @@ class Suggestions<A> extends React.Component<Props<A>, State> {
let top = anchorRect.bottom + 4;
const bottom = top + rectHeight
if (bottom > heightThreshold && anchorRect.top - window.scrollY - rectHeight - 8 > marginThreshold) {
top = anchorRect.top - 8 - rectHeight;
this.paperEl.style.bottom = - (anchorRect.top - 8) + 'px';
} else {
this.paperEl.style.top = top + 'px';
}
this.paperEl.style.top = top + 'px';
this.paperEl.style.left = anchorRect.left + 'px';
this.paperEl.style.minWidth = anchorRect.width + 'px';
};
......@@ -179,12 +182,12 @@ class Suggestions<A> extends React.Component<Props<A>, State> {
}
render() {
const { __, classes, open, pending, suggestions } = this.props;
const { __, classes, open, pending, suggestions, before, after } = this.props;
const PaperProps = { ref: this.handlePaperRef, className: classes.paper };
return <Modal className={classes.modal} open={!!open} onClose={this.handleClose} disableAutoFocus disableEnforceFocus disableRestoreFocus hideBackdrop>
<Paper {...PaperProps}>
{!!suggestions.length && <MenuList className={classes.menu}>{this.renderSuggestions(suggestions)}</MenuList>}
{!!suggestions.length && <MenuList className={classes.menu}>{before}{this.renderSuggestions(suggestions)}{after}</MenuList>}
{!suggestions.length && <div className={classes.empty}><span>{__('Nothing found…')}</span></div>}
<PendingOverlay pending={pending || false}/>
</Paper>
......@@ -192,7 +195,7 @@ class Suggestions<A> extends React.Component<Props<A>, State> {
}
}
export default withStyles(styles)(withGettext(require('./i18n.po.yml'))(Suggestions));
export default withStyles(styles)(withGettext(require('./i18n'))(Suggestions));
// Styles
......
......@@ -10,7 +10,7 @@ const eyeSlash = require('./eye-slash.svg');
// Props
export type Props = StandardProps<React.HTMLProps<HTMLInputElement>, ClassKey, 'ref'|'disabled'> & FieldProps<string> & {
export type Props = StandardProps<React.HTMLProps<HTMLInputElement>, ClassKey, 'ref'|'disabled'|'value'> & FieldProps<string> & {
__: Gettext;
ctx?: LocaleCtx;
type?: React.HTMLProps<HTMLInputElement>['type'];
......@@ -115,7 +115,7 @@ export class TextField extends React.Component<Props, State> {
}
}
export default withGettext(require('./i18n.po.yml'))(TextField);
export default withGettext(require('./i18n'))(TextField);
// CSS классы
......@@ -128,15 +128,22 @@ function styles(theme: Theme): StyleRules<ClassKey> {
return {
root: {
height: unit * 4.5,
height: unit * 4,
boxSizing: 'border-box',
display: 'inline-flex',
alignItems: 'center',
background: `rgba(0,0,0,0.04)`,
borderRadius: 3,
position: 'relative',
paddingRight: unit * 0.5,
'&$error': {
background: `rgba(255,0,0,0.08)`,
'& .material-icons': {
color: `rgba(255,0,0,0.54)`,
},
},
'& .material-icons': {
fontSize: 18,
},
},
......@@ -153,7 +160,7 @@ function styles(theme: Theme): StyleRules<ClassKey> {
height: '100%',
width: '100%',
boxSizing: 'border-box',
fontSize: 18,
fontSize: 14,
color: theme.palette.text.primary,
'&[disabled]': {
color: theme.palette.text.disabled,
......@@ -178,6 +185,7 @@ function styles(theme: Theme): StyleRules<ClassKey> {
},
'&::placeholder': {
fontStyle: 'italic',
fontSize: 14,
},
'&$error::placeholder': {
color: `rgba(255,0,0,0.54)`,
......
# https://git.bitmaster.ru/npm/common/tree/master/gettext#%D0%A4%D0%BE%D1%80%D0%BC%D0%B0%D1%82-poyml
- "":
locale: "ru-RU"
"Fill out this field": "Заполните это поле"
"Nothing found…": "Ничего не найдено…"
export default (function (webpackContext) {
return webpackContext.keys().map(webpackContext);
})(require.context('./', false, /.*\.po$/));
msgid ""
msgstr ""
"Language: ru_RU\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
msgid "Cannot be empty"
msgstr "Заполните это поле"
msgid "Nothing found…"
msgstr "Ничего не найдено…"
msgid "Clear"
msgstr "Очистить"
msgid "See more"
msgstr "Загрузить еще"
......@@ -7,4 +7,5 @@ export default function findMap<A, B>(xs: A[], f: (a: A) => B|undefined): B|unde
const b_or_undefined = f(x);
if (b_or_undefined !== undefined) return b_or_undefined;
}
return;
}
......@@ -8,28 +8,28 @@ const CONTEXT_DELIMITER = String.fromCharCode(4);
// Translations
export type Data = Record<string, Record<string, string[]|string>>;
export type JedFormat = { locale_data: { messages: Record<string, string[]> } };
export type TranslationsData = Record<string, Record<string, string[]|string>>;
export type POData = Record<string, string[]|string>;
export type ES6Module<T> = { default: T };
// Хелпер для перевода
export type Gettext = (singular_key: string, context?: string, plural_key?: string, val?: number) => string;
export type Gettext = (singular_key: string, plural_key?: string, context?: string, val?: number) => string;
export type I18nString = (locale?: string|LocaleCtx|null) => string;
export class Translations {
constructor(
readonly data: Data
readonly data: TranslationsData,
) {}
static fromJed(...data: JedFormat[]): Translations;
static fromJed(...data: ES6Module<JedFormat>[]): Translations;
static fromJed(...data: JedFormat[][]): Translations;
static fromJed(...data: ES6Module<JedFormat>[][]): Translations;
static fromJed(...webpackContexts) {
const jedTranslations: JedFormat[] = flattenModules(webpackContexts);
return new Translations(assignJedData({}, ...jedTranslations));
static fromPO(...data: POData[]): Translations;
static fromPO(...data: ES6Module<POData>[]): Translations;
static fromPO(...data: POData[][]): Translations;
static fromPO(...data: ES6Module<POData>[][]): Translations;
static fromPO(...webpackContexts) {
const podata: POData[] = flattenModules(webpackContexts);
return new Translations(assignData({}, ...podata));
function flattenModules(xs: any[]) {
return Array.prototype.concat.apply([], xs.map(x => {
......@@ -80,7 +80,7 @@ export type LocaleCtx = { locale: string };
* ```
*/
export function withGettext(...webpackContexts) {
const translations: Translations = webpackContexts[0] instanceof Translations ? webpackContexts[0] : Translations.fromJed(...webpackContexts);
const translations: Translations = webpackContexts[0] instanceof Translations ? webpackContexts[0] : Translations.fromPO(...webpackContexts);
return <P extends { __: Gettext, ctx?: { locale: string } }>(Component: React.ComponentType<P>) => {
class WithTranslations extends React.Component<Omit<P, '__'>> {
......@@ -104,27 +104,21 @@ export function withGettext(...webpackContexts) {
/**
* Добавление переводов из `srcs` в `dst`
*/
function assignJedData(dst: Data, ...srcs: JedFormat[]): Data {
function assignData(dst: TranslationsData, ...srcs: POData[]): TranslationsData {
srcs.forEach(data => {
// @ts-ignore
const locale_ = data[''].language || data[''].lang || data[''].locale; if (!locale_ || typeof(locale_) !== 'string') return;
const locale = locale_.replace(/_/g, '-');
const podata = data['%podata'];
for (const k of Object.keys(data)) {
if (k === '' || k === '%podata') continue;
if (k === '') continue;
dst[locale] = dst[locale] || {};
if (Array.isArray(data[k]) && data[k][0] === null) {
if (Array.isArray(data[k])) {
dst[locale][k] = data[k].slice(1);
if (dst[locale][k].length === 1) dst[locale][k] = dst[locale][k][0];
} else {
dst[locale][k] = data[k];
}
}
if (podata) for (const explicit of podata) {
const key = explicit.msgctxt ? explicit.msgctxt + CONTEXT_DELIMITER + explicit.msgid : explicit.msgid;
dst[locale] = dst[locale] || {};
dst[locale][key] = explicit.msgstr;
}
});
return dst;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment