Commit 876ca5fc by Vladislav Lagunov

Исправления в импортах

parent 444c887c
...@@ -403,8 +403,8 @@ export function optional(key: string|string[], dec: Decoder<any>, def: any): Dec ...@@ -403,8 +403,8 @@ export function optional(key: string|string[], dec: Decoder<any>, def: any): Dec
/** /**
* Создание декодера перечислением всех допустимых значений * Создание декодера перечислением всех допустимых значений
*/ */
export function variants<A extends Expr[]>(...array: A): Variants<A[number]>;
export function variants<A extends Expr[]>(array: A): Variants<A[number]>; export function variants<A extends Expr[]>(array: A): Variants<A[number]>;
export function variants<A extends Expr[]>(...array: A): Variants<A[number]>;
export function variants(): Variants<any> { export function variants(): Variants<any> {
return new Variants(Array.isArray(arguments[0]) ? arguments[0] : Array.prototype.slice.call(arguments)); return new Variants(Array.isArray(arguments[0]) ? arguments[0] : Array.prototype.slice.call(arguments));
} }
......
import { Err, AuthCtx } from "~/context"; import { Err, AuthCtx } from "~/context";
import { Cmd, noop, Eff, cmd } from "@bitmaster/core"; import { Cmd, noop, Eff, cmd } from "../../core";
/** /**
......
...@@ -9,7 +9,7 @@ import Select, { SelectProps } from '@material-ui/core/Select'; ...@@ -9,7 +9,7 @@ import Select, { SelectProps } from '@material-ui/core/Select';
import { AuthCtx as Ctx } from '~/context'; import { AuthCtx as Ctx } from '~/context';
import { Err, notifyError } from '~/context'; import { Err, notifyError } from '~/context';
import * as Rx from 'rxjs'; import * as Rx from 'rxjs';
import { Eff } from '@bitmaster/core'; import { Eff } from '../core';
// Источник для опций // Источник для опций
...@@ -25,7 +25,7 @@ export type AsyncSource<A> = { ...@@ -25,7 +25,7 @@ export type AsyncSource<A> = {
// Props // Props
export type Props<A = any> = StandardProps<React.HTMLProps<HTMLDivElement>, ClassKey> & FieldProps<A> & { export type Props<A = any> = StandardProps<React.HTMLProps<HTMLDivElement>, ClassKey, 'disabled'> & FieldProps<A> & {
selectProps?: Partial<SelectProps>; selectProps?: Partial<SelectProps>;
source: Source<A>; source: Source<A>;
renderItem?(item: A): string; renderItem?(item: A): string;
...@@ -125,7 +125,7 @@ export default class SelectField<A = any> extends React.Component<Props<A>, Stat ...@@ -125,7 +125,7 @@ export default class SelectField<A = any> extends React.Component<Props<A>, Stat
const valueIdx = options.findIndex(x => predicate(x, value!)); const valueIdx = options.findIndex(x => predicate(x, value!));
return ( return (
<Select value={valueIdx} onChange={this.handleChange} className={rootClass} disabled={disabled} open={open} onOpen={this.handleOpen} onClose={this.handleClose}> <Select value={valueIdx} onChange={this.handleChange} className={rootClass} disabled={Boolean(disabled)} open={open} onOpen={this.handleOpen} onClose={this.handleClose}>
{nullCase && <MenuItem key="@null" value={-1}>{nullCase}</MenuItem>} {nullCase && <MenuItem key="@null" value={-1}>{nullCase}</MenuItem>}
{options.map((item, idx) => <MenuItem key={idx} value={idx}>{(renderItem || String)(item)}</MenuItem>)} {options.map((item, idx) => <MenuItem key={idx} value={idx}>{(renderItem || String)(item)}</MenuItem>)}
</Select> </Select>
......
...@@ -4,7 +4,7 @@ import { Expr } from '../types'; ...@@ -4,7 +4,7 @@ import { Expr } from '../types';
import * as decoders from './decoders'; import * as decoders from './decoders';
import { Decoder, Validation } from '../decoder'; import { Decoder, Validation } from '../decoder';
import { Left, Right } from '../either'; import { Left, Right } from '../either';
import { Params } from '../http'; import { Params } from '../core/http';
/** Ресурс jsonapi */ /** Ресурс jsonapi */
...@@ -85,7 +85,7 @@ export class JsonApiBase<A> { ...@@ -85,7 +85,7 @@ export class JsonApiBase<A> {
case 'RelatedMany': { case 'RelatedMany': {
acc.fields[ty] = acc.fields[ty] || {}; acc.fields[ty] = acc.fields[ty] || {};
acc.fields[ty][key] = true; acc.fields[ty][key] = true;
if (!isEmpty(field.children.desc)) acc.include[path.concat(key).join('.')] = true; if (!isEmpty(field.child.desc)) acc.include[path.concat(key).join('.')] = true;
for (let k in field.child.desc) { for (let k in field.child.desc) {
if (!field.child.desc.hasOwnProperty(k)) continue; if (!field.child.desc.hasOwnProperty(k)) continue;
go(acc, path.concat(key), field.child.type, k, field.child.desc[k]); go(acc, path.concat(key), field.child.type, k, field.child.desc[k]);
......
import * as t from '../../decoder'; export * from '../../jsonapi/decoders';
import { Decoder, decoder, required, optional, Validation } from '../../decoder';
import { success, failure } from '../../either';
/** tolarate numbers as strings */
export const stringOrNumber: Decoder<string> = decoder(
'stringOrNumber', v => typeof (v) === 'string' || typeof (v) === 'number' ? success(String(v)) : failure('not a string nor a number')
);
/** top level http://jsonapi.org/format/#document-top-level */
export interface Document {
data: Resource | Resource[] | null | undefined;
meta: Record<string, JSON> | null;
included: Resource[] | null;
jsonapi: Jsonapi | null;
links: { [key: string]: Link } | null;
}
/** a document, containing errors */
export interface Errors {
errors: ErrorObject[],
meta: Object | null;
included: Resource[] | null;
jsonapi: Jsonapi | null;
links: { [key: string]: Link } | null;
}
/** error obeject http://jsonapi.org/format/#error-objects */
export interface ErrorObject {
id: string | null;
links: { [s: string]: Link } | null;
status: string | null;
code: string | null;
title: string | null;
detail: string | null;
source: { pointer: string, parameter: string } | null;
}
/** jsonapi resource http://jsonapi.org/format/#document-resource-objects */
export interface Resource {
id: string;
type: string;
attributes: { [k: string]: any } | null;
relationships: { [k: string]: Relationship } | null;
meta: Object | null;
}
/** link object http://jsonapi.org/format/#document-links */
export type Link = string | {
href: string;
meta: Object | null;
}
/** attributes http://jsonapi.org/format/#document-resource-object-attributes */
export type Attributes = Object;
/** relationship http://jsonapi.org/format/#document-resource-object-relationships */
export interface Relationship {
data: Linkage;
meta: Object | null;
links: { [k: string]: Link } | null;
}
/** linkage http://jsonapi.org/format/#document-resource-object-linkage */
export type Linkage = null | Identifier | Identifier[]
/** resource identifier http://jsonapi.org/format/#document-resource-identifier-objects */
export interface Identifier {
id: string;
type: string;
meta: object|null;
}
/** jsonapi version http://jsonapi.org/format/#document-jsonapi-object */
export interface Jsonapi {
version: string;
}
/** identifier validation */
export const identifier = t.ap(
required('id', stringOrNumber),
required('type', t.string),
optional('meta', tolerantDict(t.any), null),
(id, type, meta) => ({ id, type, meta })
);
/** linkage validation */
export const linkage = t.oneOf(t.null, identifier, t.array(identifier));
/** link validation */
export const link = t.oneOf(t.string, t.ap(
required('href', t.string),
optional('meta', tolerantDict(t.any), null),
(href, meta) => ({ href, meta })
));
/** relationship validation */
export const relationship = t.ap(
required('data', linkage),
optional('meta', tolerantDict(t.any), null),
optional('links', tolerantDict(link), null),
(data, meta, links) => ({ data, meta, links })
);
/** resource validation */
export const resource: Decoder<Resource> = t.ap(
required('id', stringOrNumber),
required('type', t.string),
optional('attributes', tolerantDict(t.any), null),
optional('relationships', tolerantDict(relationship), null),
optional('meta', tolerantDict(t.any), null),
(id, type, attributes, relationships, meta) => ({ id, type, attributes, relationships, meta })
);
/** jsonapi version validation */
export const jsonapi = required('version', t.string).map(version => ({ version }));
/** document validation */
export const document = t.ap(
optional('data', t.oneOf(resource, t.array(resource), t.null), undefined),
optional('meta', tolerantDict(t.any), null),
optional('included', t.array(resource), null),
optional('jsonapi', jsonapi, null),
optional('links', tolerantDict(link), null),
(data, meta, included, jsonapi, links) => ({ data, meta, included, jsonapi, links })
);
/** decode error object */
export const errorObject = t.ap(
optional('id', t.string, null),
optional('links', tolerantDict(link), null),
optional('status', stringOrNumber, null), /* TODO: actualy can only be a string */
optional('code', t.string, null),
optional('title', t.string, null),
optional('detail', t.string, null),
optional('details', t.string, null), /* HACK: due to TM quirks, this field doesn't appear in specification */
optional('source', t.record({ pointer: t.string, parameter: t.string }), null),
(id, links, status, code, title, detail, details, source) =>
({ id, links, status, code, title, detail: detail || details, source })
);
/** errors decoder */
export const errors = t.ap(
required('errors', t.array(errorObject)),
optional('meta', tolerantDict(t.any), null),
optional('jsonapi', jsonapi, null),
optional('links', tolerantDict(link), null),
(errors, meta, jsonapi, links) => ({ errors, meta, jsonapi, links })
);
/** validate attributes */
export function attributes<A>(res: Resource, dec: Decoder<A>): Validation<A> {
return dec.validate(res.attributes);
}
/** validate primary resource */
export function primary(doc: Document): Validation<Resource> {
return Object.prototype.toString.call(doc.data) === '[object Object]'
? success(doc.data as Resource)
: failure(`jsonapi.primary: jsonapi document doesn't contain primary resorce`);
}
/** validate resource collection */
export function collection(doc: Document): Validation<Resource[]> {
return Array.isArray(doc.data)
? success(doc.data)
: failure('jsonapi.collection: jsonapi document doesn\'t contain collection of resorces');
}
/** meta */
export function meta<A>(doc: Document, dec: Decoder<A>): Validation<A> {
return dec.validate(doc.meta);
}
/** validate related resource */
export function related(doc: Document, res: Resource, name: string): Validation<Resource> {
if (doc.included && res.relationships && res.relationships[name]) {
const rel: Relationship = res.relationships[name];
if (rel.data !== null && !Array.isArray(rel.data)) {
const { id, type } = rel.data;
const result = doc.included.find(a => a.id === id && a.type === type);
return result ? success(result) : failure(`jsonapi.related: cannot find related resource ${name} -> '${type}', #${id} for '${res.type}', #${res.id}`) as Validation<Resource>;
}
}
return failure(`jsonapi.related: cannot find related resource ${name} for '${res.type}', #${res.id}`);
}
/** validate related resource (one-to-many) */
export function relatedCollection(doc: Document, res: Resource, name: string): Validation<Resource[]> {
if (doc.included && res.relationships && res.relationships[name]) {
const rel: Relationship = res.relationships[name];
if (Array.isArray(rel.data)) {
const acc: Resource[] = [];
for (let i in rel.data) {
const { id, type } = rel.data[i];
const result = doc.included.find(a => a.id === id && a.type === type);
if (result) {
acc.push(result);
} else {
return failure(`jsonapi.relatedCollection: cannot find related resource '${name}', id: ${id}`);
}
}
return success(acc);
}
}
return failure(`jsonapi.relatedCollection: cannot find related resource '${name}' for '${res.type}', #${res.id}`);
}
/** validate related resource (only identifier) */
export function relatedLinkage(res: Resource, name: string): Validation<Identifier> {
if (res.relationships && res.relationships[name]) {
const rel: Relationship = res.relationships[name];
return rel.data !== null && !Array.isArray(rel.data) ? success(rel.data) : failure(`jsonapi.relatedLinkage: cannot find relationship '${name}' for ${res.type}#${res.id}`);
}
return failure(`relatedLinkage: trying to access relationship '${name}' on ${res.type}#${res.id}: resource doesn't have relationships`);
}
/** validate related resource collection (only identifiers) */
export function relatedLinkageCollection(res: Resource, name: string): Validation<Identifier[]> {
if (res.relationships && res.relationships[name]) {
const rel: Relationship = res.relationships[name];
return Array.isArray(rel.data) ? success(rel.data) : failure(`relatedLinkageCollection: cannot find relationship '${name}' for ${res.type}#${res.id}`);
}
return failure(`relatedLinkageCollection: trying to access relationship '${name}' on ${res.type}#${res.id}: resource doesn't have relationships`);
}
/** unlike `t.dict` this version ignores invalid values */
export function tolerantDict<A>(d: Decoder<A>): Decoder<Record<string, A>> {
return decoder('jsonapi.tolerantDict', value => {
if (Object.prototype.toString.call(value) !== '[object Object]') return failure('not an object');
const output = {} as Record<string, A>;
for (let key in value) {
if (!value.hasOwnProperty(key)) continue;
const ethr = d.validate(value[key]);
switch(ethr.tag) {
case 'Left': continue;
case 'Right': output[key] = ethr.value; break;
}
}
return success(output);
});
}
import * as decoders from './decoders'; export * from '../../jsonapi';
export * from './resources';
export { decoders };
import { isEqual, isEmpty } from 'lodash'; export * from '../../jsonapi/resources';
import { decode as t, either, success, failure } from '@bitmaster/core';
import { Expr } from '@bitmaster/core/internal/expr';
import * as decoders from './decoders';
import { Decoder, Validation } from '@bitmaster/core/decode';
import { Left, Right } from '@bitmaster/core/either';
import { Params } from '@bitmaster/core/http';
/** Ресурс jsonapi */
export interface Resource<T extends string = string> {
id: string;
type: T;
}
/** Вспомогательный тип для построения GET параметров */
export interface Flags {
fields: Record<string, StringSet>;
include: StringSet;
}
export type StringSet = Record<string, true>;
/** DSL для jsonapi ресурсов */
export type JsonApi<A> =
| Attr<A>
| Related<A>
| RelatedMany<A>
| WithName<A>
| WithDefault<A>
// Базовый класс для наследования методов
export class JsonApiBase<A> {
readonly _A: A;
/**
* Установка дефолтного значения в случае отсутствия поля
*/
withDefault(this: JsonApi<A>, def: A): WithDefault<A>;
withDefault<B extends Expr>(this: JsonApi<A>, def: B): WithDefault<A|B>;
withDefault<B extends Expr>(this: JsonApi<A>, def: B): WithDefault<A|B> {
return new WithDefault(def, this);
}
/**
* Создать `to-many` поле
*/
many(this: Related<A>): RelatedMany<A[]>{
return new RelatedMany(this as any);
}
/**
* Сбор информации по полям ресурсов для построения GET запроса
*/
collectFlags(this: Related<A>): Flags {
const output: Flags = { fields: {}, include: {} };
for (let key in this.desc) {
if (!this.desc.hasOwnProperty(key)) continue;
go(output, [], this.type, key, this.desc[key]);
}
return output;
function go(acc: Flags, path: string[], ty: string, key: string, field: JsonApi<any>) {
switch(field.tag) {
case 'Attr': {
acc.fields[ty] = acc.fields[ty] || {};
acc.fields[ty][key] = true;
return;
}
case 'Related': {
acc.fields[ty] = acc.fields[ty] || {};
acc.fields[ty][key] = true;
if (!isEmpty(field.desc)) acc.include[path.concat(key).join('.')] = true;
for (let k in field.desc) {
if (!field.desc.hasOwnProperty(k)) continue;
go(acc, path.concat(key), field.type, k, field.desc[k]);
}
return;
}
case 'RelatedMany': {
acc.fields[ty] = acc.fields[ty] || {};
acc.fields[ty][key] = true;
if (!isEmpty(field.child.desc)) acc.include[path.concat(key).join('.')] = true;
for (let k in field.child.desc) {
if (!field.child.desc.hasOwnProperty(k)) continue;
go(acc, path.concat(key), field.child.type, k, field.child.desc[k]);
}
return;
}
case 'WithDefault': {
go(acc, path, ty, key, field.child);
return;
}
case 'WithName': {
go(acc, path, ty, field.name, field.child);
return;
}
}
}
}
/**
* Построение GET запроса
*/
collectQuery(this: Related<A>): Params {
const { fields, include } = this.collectFlags();
const output = { include: Object.keys(include).join(',') || undefined, } as Params;
for (let k in fields) {
output[`fields[${k}]`] = Object.keys(fields[k]).join(',') || undefined;
}
return output;
}
/**
* Валидация jsonapi документа
*/
validate(this: Related<A>, doc: decoders.Document, res: decoders.Resource): Validation<A> {
if (res.type !== this.type) return failure(`invalid resource type: ${res.type}, expected ${this.type}`);
const output: Record<string, any> = { id: res.id, type: res.type };
for (let key in this.desc) {
if (!this.desc.hasOwnProperty(key)) continue;
const result = helperRec(key, this.desc[key], doc, res);
if (result instanceof Left) return result;
output[key] = result['value'];
}
return success(output as A);
function helperRec(key: string, field: JsonApi<any>, doc: decoders.Document, res: decoders.Resource): Validation<any> {
switch(field.tag) {
case 'Attr': {
const attrs = res.attributes;
if (attrs === null) return failure(`resource ${res.type}#${res.id} doesn't have any attributes`);
return field.decoder.validate(attrs[key]);
}
case 'Related': {
if (isEmpty(field.desc)) return decoders.relatedLinkage(res, key);
return decoders.related(doc, res, key).chain(relatedRes => field.validate(doc, relatedRes));
}
case 'RelatedMany': {
return decoders.relatedCollection(doc, res, key).chain(rs => either.traverse(rs, x => field.child.validate(doc, x)));
}
case 'WithDefault': {
const nested = field.child;
switch(nested.tag) {
case 'Related': return decoders.related(doc, res, key).fold(() => success(field.defaultValue), relatedRes => nested.validate(doc, relatedRes));
case 'RelatedMany': return decoders.relatedCollection(doc, res, key).fold(() => success(field.defaultValue), rs => either.traverse(rs, x => nested.child.validate(doc, x)));
}
const result = helperRec(key, field.child, doc, res);
if (result instanceof Right) return result;
return success(field.defaultValue);
}
case 'WithName': {
return helperRec(field.name, field.child, doc, res)
}
}
}
}
/**
* Построение содержимого POST запроса
*/
post(this: Related<A>, resource: A): object {
const data: Record<string, any> = { type: this.type };
const attributes: Record<string, any> = {};
const relationships: Record<string, any> = {};
for (let key in this.desc) {
if (!this.desc.hasOwnProperty(key)) continue;
postRec(key, key, this.desc[key]);
}
if (!isEmpty(attributes)) data.attributes = attributes;
if (!isEmpty(relationships)) data.relationships = relationships;
return { data };
function postRec(ikey: string, okey: string, field: JsonApi<any>) {
switch(field.tag) {
case 'Attr':
attributes[okey] = resource[ikey];
return;
case 'Related':
relationships[okey] = { data: { type: resource[ikey].type, id: resource[ikey].id } };
return;
case 'RelatedMany':
relationships[okey] = { data: resource[ikey].map(x => ({ type: x.type, id: x.id })) };
return;
case 'WithDefault':
if (!isEqual(resource[ikey], field.defaultValue)) postRec(ikey, okey, field.child);
return;
case 'WithName':
postRec(ikey, field.name, field.child);
return;
}
}
}
/**
* Построение содержимого PATCH запроса
*/
patch(this: Related<A>, id: string, patch: Partial<A>): object {
const data: Record<string, any> = { type: this.type, id };
const attributes: Record<string, any> = {};
const relationships: Record<string, any> = {};
for (let key in patch) {
if (!this.desc.hasOwnProperty(key)) continue;
patchRec(key, key, this.desc[key]);
}
if (!isEmpty(attributes)) data.attributes = attributes;
if (!isEmpty(relationships)) data.relationships = relationships;
return { data };
function patchRec(ikey: string, okey: string, field: JsonApi<any>) {
switch(field.tag) {
case 'Attr':
attributes[okey] = patch[ikey];
return;
case 'Related':
relationships[okey] = { data: { type: patch[ikey].type, id: patch[ikey].id } };
return;
case 'RelatedMany':
relationships[okey] = { data: patch[ikey].map(x => ({ type: x.type, id: x.id })) };
return;
case 'WithDefault':
if (isEqual(patch[ikey], field.defaultValue)) {
switch (field.child.tag) {
case 'Attr': attributes[okey] = field.defaultValue; return;
case 'Related': relationships[okey] = { data: null }; return;
case 'RelatedMany': relationships[okey] = { data: [] }; return;
default: console.warn(`invalid tag nested inside WithDefault ${field.child.tag}`); return;
}
}
patchRec(ikey, okey, field.child);
return;
case 'WithName':
patchRec(ikey, field.name, field.child);
return;
}
}
}
/**
* Декодер для primary документа
*/
primaryDecoder(this: Related<A>): Decoder<A> {
return t.decoder('primary', val => decoders.document.validate(val).chain(
doc => decoders.primary(doc).chain(res => this.validate(doc, res))
));
}
/**
* Декодер для коллекции документов
*/
collectionDecoder(this: Related<A>): Decoder<A[]> {
return t.decoder('collection', val => decoders.document.validate(val).chain(
doc => ensureCollection(doc).chain(
rs => either.traverse(rs, res => this.validate(doc, res))
))
);
function ensureCollection(doc: decoders.Document): Validation<Array<decoders.Resource>>{
return Array.isArray(doc.data) ? success(doc.data) : failure(`jsonapi document doesn't contain collection of resorces`);
}
}
/**
* Расширение набора полей
*/
extend<R extends ResourceRecord>(this: Related<A>, desc: R): Related<A & { [K in keyof R]: R[K]['_A'] }> {
const replaceDecoderWithAttr = { ...this.desc } as Related<any>['desc'];
if (desc) for (let k in desc) {
if (!desc.hasOwnProperty(k)) continue;
if (desc[k] instanceof t.DecoderBase) replaceDecoderWithAttr[k] = new Attr(desc[k] as any);
else replaceDecoderWithAttr[k] = desc[k] as any;
}
return new Related(this.type, replaceDecoderWithAttr);
}
/** Переименование поля */
withName(name: string): WithName<A> {
return new WithName(name, this as any);
}
}
/**
* Атрибут ресурса
*/
export class Attr<A> extends JsonApiBase<A> {
readonly tag: 'Attr' = 'Attr';
constructor(
readonly decoder: Decoder<A>,
) { super(); }
}
/**
* Связанный ресурс
*/
export class Related<A> extends JsonApiBase<A> {
readonly tag: 'Related' = 'Related';
constructor(
readonly type: string,
readonly desc: Record<string, JsonApi<any>>,
) { super(); }
}
/**
* Связанный ресурс (to-many)
*/
export class RelatedMany<A> extends JsonApiBase<A> {
readonly tag: 'RelatedMany' = 'RelatedMany';
constructor(
readonly child: Related<any>, // A[number]
) { super(); }
}
/**
* Поле с fallback значением
*/
export class WithDefault<A> extends JsonApiBase<A> {
readonly tag: 'WithDefault' = 'WithDefault';
constructor(
readonly defaultValue: A,
readonly child: JsonApi<any>,
) { super(); }
}
/**
* Поле с переименованием
*/
export class WithName<A> extends JsonApiBase<A> {
readonly tag: 'WithName' = 'WithName';
constructor(
readonly name: string,
readonly child: JsonApi<any>,
) { super(); }
}
// Тип аргумента для `resource`
export type ResourceRecord = Record<string, Decoder<any>|JsonApi<any>>;
/**
* Построение инстанса для `Related`
*/
export function resource<R extends ResourceRecord, T extends string>(type: T): Related<Resource<T>>;
export function resource<R extends ResourceRecord, T extends string>(type: T, desc: R): Related<Resource<T> & { [K in keyof R]: R[K]['_A'] }>;
export function resource<R extends ResourceRecord, T extends string>(type: T, desc?: R): Related<any> {
const replaceDecoderWithAttr = {} as Related<any>['desc'];
if (desc) for (let k in desc) {
if (!desc.hasOwnProperty(k)) continue;
if (desc[k] instanceof t.DecoderBase) replaceDecoderWithAttr[k] = new Attr(desc[k] as any);
else replaceDecoderWithAttr[k] = desc[k] as any;
}
return new Related(type, replaceDecoderWithAttr);
}
/** Конструцтор атрибута */
export function attr<A>(decoder: Decoder<A>): Attr<A> {
return new Attr(decoder);
}
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