Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
N
nexpie-grafana-theme
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Registry
Registry
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Kornkitt Poolsup
nexpie-grafana-theme
Commits
e761fb19
Unverified
Commit
e761fb19
authored
Oct 23, 2018
by
David
Committed by
GitHub
Oct 23, 2018
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #13761 from grafana/davkal/explore-reuse-table-merge
Explore: reuse table merge from table panel
parents
2107f88f
374fe9dc
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
237 additions
and
146 deletions
+237
-146
public/app/core/specs/table_model.test.ts
+116
-1
public/app/core/table_model.ts
+109
-1
public/app/features/explore/Explore.tsx
+5
-2
public/app/features/explore/Table.tsx
+3
-0
public/app/plugins/panel/table/specs/transformers.test.ts
+0
-48
public/app/plugins/panel/table/transformers.ts
+4
-94
No files found.
public/app/core/specs/table_model.test.ts
View file @
e761fb19
import
TableModel
from
'app/core/table_model'
;
import
TableModel
,
{
mergeTablesIntoModel
}
from
'app/core/table_model'
;
describe
(
'when sorting table desc'
,
()
=>
{
let
table
;
...
...
@@ -79,3 +79,118 @@ describe('when sorting with nulls', () => {
expect
(
values
).
toEqual
([
null
,
null
,
'd'
,
'c'
,
'b'
,
'a'
,
''
,
''
]);
});
});
describe
(
'mergeTables'
,
()
=>
{
const
time
=
new
Date
().
getTime
();
const
singleTable
=
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Value'
}],
rows
:
[[
time
,
'Label Value 1'
,
42
]],
});
const
multipleTablesSameColumns
=
[
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Label Key 2'
},
{
text
:
'Value #A'
}],
rows
:
[[
time
,
'Label Value 1'
,
'Label Value 2'
,
42
]],
}),
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Label Key 2'
},
{
text
:
'Value #B'
}],
rows
:
[[
time
,
'Label Value 1'
,
'Label Value 2'
,
13
]],
}),
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Label Key 2'
},
{
text
:
'Value #C'
}],
rows
:
[[
time
,
'Label Value 1'
,
'Label Value 2'
,
4
]],
}),
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Label Key 2'
},
{
text
:
'Value #C'
}],
rows
:
[[
time
,
'Label Value 1'
,
'Label Value 2'
,
7
]],
}),
];
const
multipleTablesDifferentColumns
=
[
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Value #A'
}],
rows
:
[[
time
,
'Label Value 1'
,
42
]],
}),
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 2'
},
{
text
:
'Value #B'
}],
rows
:
[[
time
,
'Label Value 2'
,
13
]],
}),
new
TableModel
({
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Value #C'
}],
rows
:
[[
time
,
'Label Value 3'
,
7
]],
}),
];
it
(
'should return the single table as is'
,
()
=>
{
const
table
=
mergeTablesIntoModel
(
new
TableModel
(),
singleTable
);
expect
(
table
.
columns
.
length
).
toBe
(
3
);
expect
(
table
.
columns
[
0
].
text
).
toBe
(
'Time'
);
expect
(
table
.
columns
[
1
].
text
).
toBe
(
'Label Key 1'
);
expect
(
table
.
columns
[
2
].
text
).
toBe
(
'Value'
);
});
it
(
'should return the union of columns for multiple tables'
,
()
=>
{
const
table
=
mergeTablesIntoModel
(
new
TableModel
(),
...
multipleTablesSameColumns
);
expect
(
table
.
columns
.
length
).
toBe
(
6
);
expect
(
table
.
columns
[
0
].
text
).
toBe
(
'Time'
);
expect
(
table
.
columns
[
1
].
text
).
toBe
(
'Label Key 1'
);
expect
(
table
.
columns
[
2
].
text
).
toBe
(
'Label Key 2'
);
expect
(
table
.
columns
[
3
].
text
).
toBe
(
'Value #A'
);
expect
(
table
.
columns
[
4
].
text
).
toBe
(
'Value #B'
);
expect
(
table
.
columns
[
5
].
text
).
toBe
(
'Value #C'
);
});
it
(
'should return 1 row for a single table'
,
()
=>
{
const
table
=
mergeTablesIntoModel
(
new
TableModel
(),
singleTable
);
expect
(
table
.
rows
.
length
).
toBe
(
1
);
expect
(
table
.
rows
[
0
][
0
]).
toBe
(
time
);
expect
(
table
.
rows
[
0
][
1
]).
toBe
(
'Label Value 1'
);
expect
(
table
.
rows
[
0
][
2
]).
toBe
(
42
);
});
it
(
'should return 2 rows for a multiple tables with same column values plus one extra row'
,
()
=>
{
const
table
=
mergeTablesIntoModel
(
new
TableModel
(),
...
multipleTablesSameColumns
);
expect
(
table
.
rows
.
length
).
toBe
(
2
);
expect
(
table
.
rows
[
0
][
0
]).
toBe
(
time
);
expect
(
table
.
rows
[
0
][
1
]).
toBe
(
'Label Value 1'
);
expect
(
table
.
rows
[
0
][
2
]).
toBe
(
'Label Value 2'
);
expect
(
table
.
rows
[
0
][
3
]).
toBe
(
42
);
expect
(
table
.
rows
[
0
][
4
]).
toBe
(
13
);
expect
(
table
.
rows
[
0
][
5
]).
toBe
(
4
);
expect
(
table
.
rows
[
1
][
0
]).
toBe
(
time
);
expect
(
table
.
rows
[
1
][
1
]).
toBe
(
'Label Value 1'
);
expect
(
table
.
rows
[
1
][
2
]).
toBe
(
'Label Value 2'
);
expect
(
table
.
rows
[
1
][
3
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
4
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
5
]).
toBe
(
7
);
});
it
(
'should return 2 rows for multiple tables with different column values'
,
()
=>
{
const
table
=
mergeTablesIntoModel
(
new
TableModel
(),
...
multipleTablesDifferentColumns
);
expect
(
table
.
rows
.
length
).
toBe
(
2
);
expect
(
table
.
columns
.
length
).
toBe
(
6
);
expect
(
table
.
rows
[
0
][
0
]).
toBe
(
time
);
expect
(
table
.
rows
[
0
][
1
]).
toBe
(
'Label Value 1'
);
expect
(
table
.
rows
[
0
][
2
]).
toBe
(
42
);
expect
(
table
.
rows
[
0
][
3
]).
toBe
(
'Label Value 2'
);
expect
(
table
.
rows
[
0
][
4
]).
toBe
(
13
);
expect
(
table
.
rows
[
0
][
5
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
0
]).
toBe
(
time
);
expect
(
table
.
rows
[
1
][
1
]).
toBe
(
'Label Value 3'
);
expect
(
table
.
rows
[
1
][
2
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
3
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
4
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
5
]).
toBe
(
7
);
});
});
public/app/core/table_model.ts
View file @
e761fb19
import
_
from
'lodash'
;
interface
Column
{
text
:
string
;
title
?:
string
;
...
...
@@ -14,11 +16,20 @@ export default class TableModel {
type
:
string
;
columnMap
:
any
;
constructor
()
{
constructor
(
table
?:
any
)
{
this
.
columns
=
[];
this
.
columnMap
=
{};
this
.
rows
=
[];
this
.
type
=
'table'
;
if
(
table
)
{
if
(
table
.
columns
)
{
table
.
columns
.
forEach
(
col
=>
this
.
addColumn
(
col
));
}
if
(
table
.
rows
)
{
table
.
rows
.
forEach
(
row
=>
this
.
addRow
(
row
));
}
}
}
sort
(
options
)
{
...
...
@@ -52,3 +63,100 @@ export default class TableModel {
this
.
rows
.
push
(
row
);
}
}
// Returns true if both rows have matching non-empty fields as well as matching
// indexes where one field is empty and the other is not
function
areRowsMatching
(
columns
,
row
,
otherRow
)
{
let
foundFieldToMatch
=
false
;
for
(
let
columnIndex
=
0
;
columnIndex
<
columns
.
length
;
columnIndex
++
)
{
if
(
row
[
columnIndex
]
!==
undefined
&&
otherRow
[
columnIndex
]
!==
undefined
)
{
if
(
row
[
columnIndex
]
!==
otherRow
[
columnIndex
])
{
return
false
;
}
}
else
if
(
row
[
columnIndex
]
===
undefined
||
otherRow
[
columnIndex
]
===
undefined
)
{
foundFieldToMatch
=
true
;
}
}
return
foundFieldToMatch
;
}
export
function
mergeTablesIntoModel
(
dst
?:
TableModel
,
...
tables
:
TableModel
[]):
TableModel
{
const
model
=
dst
||
new
TableModel
();
// Single query returns data columns and rows as is
if
(
arguments
.
length
===
2
)
{
model
.
columns
=
[...
tables
[
0
].
columns
];
model
.
rows
=
[...
tables
[
0
].
rows
];
return
model
;
}
// Track column indexes of union: name -> index
const
columnNames
=
{};
// Union of all non-value columns
const
columnsUnion
=
tables
.
slice
().
reduce
((
acc
,
series
)
=>
{
series
.
columns
.
forEach
(
col
=>
{
const
{
text
}
=
col
;
if
(
columnNames
[
text
]
===
undefined
)
{
columnNames
[
text
]
=
acc
.
length
;
acc
.
push
(
col
);
}
});
return
acc
;
},
[]);
// Map old column index to union index per series, e.g.,
// given columnNames {A: 0, B: 1} and
// data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]]
const
columnIndexMapper
=
tables
.
map
(
series
=>
series
.
columns
.
map
(
col
=>
columnNames
[
col
.
text
]));
// Flatten rows of all series and adjust new column indexes
const
flattenedRows
=
tables
.
reduce
((
acc
,
series
,
seriesIndex
)
=>
{
const
mapper
=
columnIndexMapper
[
seriesIndex
];
series
.
rows
.
forEach
(
row
=>
{
const
alteredRow
=
[];
// Shifting entries according to index mapper
mapper
.
forEach
((
to
,
from
)
=>
{
alteredRow
[
to
]
=
row
[
from
];
});
acc
.
push
(
alteredRow
);
});
return
acc
;
},
[]);
// Merge rows that have same values for columns
const
mergedRows
=
{};
const
compactedRows
=
flattenedRows
.
reduce
((
acc
,
row
,
rowIndex
)
=>
{
if
(
!
mergedRows
[
rowIndex
])
{
// Look from current row onwards
let
offset
=
rowIndex
+
1
;
// More than one row can be merged into current row
while
(
offset
<
flattenedRows
.
length
)
{
// Find next row that could be merged
const
match
=
_
.
findIndex
(
flattenedRows
,
otherRow
=>
areRowsMatching
(
columnsUnion
,
row
,
otherRow
),
offset
);
if
(
match
>
-
1
)
{
const
matchedRow
=
flattenedRows
[
match
];
// Merge values from match into current row if there is a gap in the current row
for
(
let
columnIndex
=
0
;
columnIndex
<
columnsUnion
.
length
;
columnIndex
++
)
{
if
(
row
[
columnIndex
]
===
undefined
&&
matchedRow
[
columnIndex
]
!==
undefined
)
{
row
[
columnIndex
]
=
matchedRow
[
columnIndex
];
}
}
// Don't visit this row again
mergedRows
[
match
]
=
matchedRow
;
// Keep looking for more rows to merge
offset
=
match
+
1
;
}
else
{
// No match found, stop looking
break
;
}
}
acc
.
push
(
row
);
}
return
acc
;
},
[]);
model
.
columns
=
columnsUnion
;
model
.
rows
=
compactedRows
;
return
model
;
}
public/app/features/explore/Explore.tsx
View file @
e761fb19
...
...
@@ -13,6 +13,7 @@ import ResetStyles from 'app/core/components/Picker/ResetStyles';
import
PickerOption
from
'app/core/components/Picker/PickerOption'
;
import
IndicatorsContainer
from
'app/core/components/Picker/IndicatorsContainer'
;
import
NoOptionsMessage
from
'app/core/components/Picker/NoOptionsMessage'
;
import
TableModel
,
{
mergeTablesIntoModel
}
from
'app/core/table_model'
;
import
ElapsedTime
from
'./ElapsedTime'
;
import
QueryRows
from
'./QueryRows'
;
...
...
@@ -389,8 +390,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
to
:
parseDate
(
range
.
to
,
true
),
};
const
{
interval
}
=
kbn
.
calculateInterval
(
absoluteRange
,
resolution
,
datasource
.
interval
);
const
targets
=
this
.
queryExpressions
.
map
(
q
=>
({
const
targets
=
this
.
queryExpressions
.
map
(
(
q
,
i
)
=>
({
...
targetOptions
,
// Target identifier is needed for table transformations
refId
:
i
+
1
,
expr
:
q
,
}));
return
{
...
...
@@ -437,7 +440,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
});
try
{
const
res
=
await
datasource
.
query
(
options
);
const
tableModel
=
res
.
data
[
0
]
;
const
tableModel
=
mergeTablesIntoModel
(
new
TableModel
(),
...
res
.
data
)
;
const
latency
=
Date
.
now
()
-
now
;
this
.
setState
({
latency
,
loading
:
false
,
tableResult
:
tableModel
,
requestOptions
:
options
});
this
.
onQuerySuccess
(
datasource
.
meta
.
id
,
queries
);
...
...
public/app/features/explore/Table.tsx
View file @
e761fb19
...
...
@@ -5,6 +5,8 @@ import ReactTable from 'react-table';
import
TableModel
from
'app/core/table_model'
;
const
EMPTY_TABLE
=
new
TableModel
();
// Identify columns that contain values
const
VALUE_REGEX
=
/^
[
Vv
]
alue #
\d
+/
;
interface
TableProps
{
data
:
TableModel
;
...
...
@@ -34,6 +36,7 @@ export default class Table extends PureComponent<TableProps> {
const
columns
=
tableModel
.
columns
.
map
(({
filterable
,
text
})
=>
({
Header
:
text
,
accessor
:
text
,
className
:
VALUE_REGEX
.
test
(
text
)
?
'text-right'
:
''
,
show
:
text
!==
'Time'
,
Cell
:
row
=>
<
span
className=
{
filterable
?
'link'
:
''
}
>
{
row
.
value
}
</
span
>,
}));
...
...
public/app/plugins/panel/table/specs/transformers.test.ts
View file @
e761fb19
...
...
@@ -143,24 +143,6 @@ describe('when transforming time series table', () => {
},
];
const
multipleQueriesDataDifferentLabels
=
[
{
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Value #A'
}],
rows
:
[[
time
,
'Label Value 1'
,
42
]],
},
{
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 2'
},
{
text
:
'Value #B'
}],
rows
:
[[
time
,
'Label Value 2'
,
13
]],
},
{
type
:
'table'
,
columns
:
[{
text
:
'Time'
},
{
text
:
'Label Key 1'
},
{
text
:
'Value #C'
}],
rows
:
[[
time
,
'Label Value 3'
,
7
]],
},
];
describe
(
'getColumns'
,
()
=>
{
it
(
'should return data columns given a single query'
,
()
=>
{
const
columns
=
transformers
[
transform
].
getColumns
(
singleQueryData
);
...
...
@@ -177,16 +159,6 @@ describe('when transforming time series table', () => {
expect
(
columns
[
3
].
text
).
toBe
(
'Value #A'
);
expect
(
columns
[
4
].
text
).
toBe
(
'Value #B'
);
});
it
(
'should return the union of data columns given a multiple queries with different labels'
,
()
=>
{
const
columns
=
transformers
[
transform
].
getColumns
(
multipleQueriesDataDifferentLabels
);
expect
(
columns
[
0
].
text
).
toBe
(
'Time'
);
expect
(
columns
[
1
].
text
).
toBe
(
'Label Key 1'
);
expect
(
columns
[
2
].
text
).
toBe
(
'Value #A'
);
expect
(
columns
[
3
].
text
).
toBe
(
'Label Key 2'
);
expect
(
columns
[
4
].
text
).
toBe
(
'Value #B'
);
expect
(
columns
[
5
].
text
).
toBe
(
'Value #C'
);
});
});
describe
(
'transform'
,
()
=>
{
...
...
@@ -237,26 +209,6 @@ describe('when transforming time series table', () => {
expect
(
table
.
rows
[
1
][
4
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
5
]).
toBe
(
7
);
});
it
(
'should return 2 rows for multiple queries with different label values'
,
()
=>
{
table
=
transformDataToTable
(
multipleQueriesDataDifferentLabels
,
panel
);
expect
(
table
.
rows
.
length
).
toBe
(
2
);
expect
(
table
.
columns
.
length
).
toBe
(
6
);
expect
(
table
.
rows
[
0
][
0
]).
toBe
(
time
);
expect
(
table
.
rows
[
0
][
1
]).
toBe
(
'Label Value 1'
);
expect
(
table
.
rows
[
0
][
2
]).
toBe
(
42
);
expect
(
table
.
rows
[
0
][
3
]).
toBe
(
'Label Value 2'
);
expect
(
table
.
rows
[
0
][
4
]).
toBe
(
13
);
expect
(
table
.
rows
[
0
][
5
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
0
]).
toBe
(
time
);
expect
(
table
.
rows
[
1
][
1
]).
toBe
(
'Label Value 3'
);
expect
(
table
.
rows
[
1
][
2
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
3
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
4
]).
toBeUndefined
();
expect
(
table
.
rows
[
1
][
5
]).
toBe
(
7
);
});
});
});
});
...
...
public/app/plugins/panel/table/transformers.ts
View file @
e761fb19
import
_
from
'lodash'
;
import
flatten
from
'
../../..
/core/utils/flatten'
;
import
TimeSeries
from
'
../../..
/core/time_series2'
;
import
TableModel
from
'../../..
/core/table_model'
;
import
flatten
from
'
app
/core/utils/flatten'
;
import
TimeSeries
from
'
app
/core/time_series2'
;
import
TableModel
,
{
mergeTablesIntoModel
}
from
'app
/core/table_model'
;
const
transformers
=
{};
...
...
@@ -168,97 +168,7 @@ transformers['table'] = {
};
}
// Single query returns data columns and rows as is
if
(
data
.
length
===
1
)
{
model
.
columns
=
[...
data
[
0
].
columns
];
model
.
rows
=
[...
data
[
0
].
rows
];
return
;
}
// Track column indexes of union: name -> index
const
columnNames
=
{};
// Union of all non-value columns
const
columnsUnion
=
data
.
reduce
((
acc
,
series
)
=>
{
series
.
columns
.
forEach
(
col
=>
{
const
{
text
}
=
col
;
if
(
columnNames
[
text
]
===
undefined
)
{
columnNames
[
text
]
=
acc
.
length
;
acc
.
push
(
col
);
}
});
return
acc
;
},
[]);
// Map old column index to union index per series, e.g.,
// given columnNames {A: 0, B: 1} and
// data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]]
const
columnIndexMapper
=
data
.
map
(
series
=>
series
.
columns
.
map
(
col
=>
columnNames
[
col
.
text
]));
// Flatten rows of all series and adjust new column indexes
const
flattenedRows
=
data
.
reduce
((
acc
,
series
,
seriesIndex
)
=>
{
const
mapper
=
columnIndexMapper
[
seriesIndex
];
series
.
rows
.
forEach
(
row
=>
{
const
alteredRow
=
[];
// Shifting entries according to index mapper
mapper
.
forEach
((
to
,
from
)
=>
{
alteredRow
[
to
]
=
row
[
from
];
});
acc
.
push
(
alteredRow
);
});
return
acc
;
},
[]);
// Returns true if both rows have matching non-empty fields as well as matching
// indexes where one field is empty and the other is not
function
areRowsMatching
(
columns
,
row
,
otherRow
)
{
let
foundFieldToMatch
=
false
;
for
(
let
columnIndex
=
0
;
columnIndex
<
columns
.
length
;
columnIndex
++
)
{
if
(
row
[
columnIndex
]
!==
undefined
&&
otherRow
[
columnIndex
]
!==
undefined
)
{
if
(
row
[
columnIndex
]
!==
otherRow
[
columnIndex
])
{
return
false
;
}
}
else
if
(
row
[
columnIndex
]
===
undefined
||
otherRow
[
columnIndex
]
===
undefined
)
{
foundFieldToMatch
=
true
;
}
}
return
foundFieldToMatch
;
}
// Merge rows that have same values for columns
const
mergedRows
=
{};
const
compactedRows
=
flattenedRows
.
reduce
((
acc
,
row
,
rowIndex
)
=>
{
if
(
!
mergedRows
[
rowIndex
])
{
// Look from current row onwards
let
offset
=
rowIndex
+
1
;
// More than one row can be merged into current row
while
(
offset
<
flattenedRows
.
length
)
{
// Find next row that could be merged
const
match
=
_
.
findIndex
(
flattenedRows
,
otherRow
=>
areRowsMatching
(
columnsUnion
,
row
,
otherRow
),
offset
);
if
(
match
>
-
1
)
{
const
matchedRow
=
flattenedRows
[
match
];
// Merge values from match into current row if there is a gap in the current row
for
(
let
columnIndex
=
0
;
columnIndex
<
columnsUnion
.
length
;
columnIndex
++
)
{
if
(
row
[
columnIndex
]
===
undefined
&&
matchedRow
[
columnIndex
]
!==
undefined
)
{
row
[
columnIndex
]
=
matchedRow
[
columnIndex
];
}
}
// Don't visit this row again
mergedRows
[
match
]
=
matchedRow
;
// Keep looking for more rows to merge
offset
=
match
+
1
;
}
else
{
// No match found, stop looking
break
;
}
}
acc
.
push
(
row
);
}
return
acc
;
},
[]);
model
.
columns
=
columnsUnion
;
model
.
rows
=
compactedRows
;
mergeTablesIntoModel
(
model
,
...
data
);
},
};
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment