@teselagen/ove 0.8.39 → 0.8.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ /* Copyright (C) 2018 TeselaGen Biotechnology, Inc. */
2
+ import * as chai from "chai";
3
+ import {
4
+ findAlignmentDifferences,
5
+ groupConsecutiveDifferences
6
+ } from "./findAlignmentDifferences";
7
+
8
+ chai.should();
9
+ const { expect } = chai;
10
+
11
+ describe("findAlignmentDifferences", () => {
12
+ it("returns empty array when fewer than 2 tracks", () => {
13
+ expect(findAlignmentDifferences([])).to.deep.equal([]);
14
+ expect(findAlignmentDifferences(["ACGT"])).to.deep.equal([]);
15
+ });
16
+
17
+ it("returns empty array when template is empty", () => {
18
+ expect(findAlignmentDifferences(["", ""])).to.deep.equal([]);
19
+ });
20
+
21
+ it("returns empty array when sequences are identical", () => {
22
+ expect(findAlignmentDifferences(["ACGT", "ACGT"])).to.deep.equal([]);
23
+ });
24
+
25
+ it("detects a single mismatch", () => {
26
+ // pos 1: A vs T
27
+ const diffs = findAlignmentDifferences(["ACGT", "ATGT"]);
28
+ expect(diffs).to.have.lengthOf(1);
29
+ expect(diffs[0]).to.deep.include({ position: 1, type: "mismatch" });
30
+ });
31
+
32
+ it("detects multiple mismatches", () => {
33
+ const diffs = findAlignmentDifferences(["AAAA", "TATA"]);
34
+ expect(diffs).to.have.lengthOf(2);
35
+ expect(diffs.map(d => d.position)).to.deep.equal([0, 2]);
36
+ diffs.forEach(d => expect(d.type).to.equal("mismatch"));
37
+ });
38
+
39
+ it("detects mismatches in protein sequences (amino acids)", () => {
40
+ const diffs = findAlignmentDifferences(["MAST", "MAST"]);
41
+ expect(diffs).to.have.lengthOf(0);
42
+
43
+ const diffs2 = findAlignmentDifferences(["MAST", "MVST"]);
44
+ expect(diffs2).to.have.lengthOf(1);
45
+ expect(diffs2[0]).to.deep.include({ position: 1, type: "mismatch" });
46
+ });
47
+
48
+ it("is case-insensitive", () => {
49
+ const diffs = findAlignmentDifferences(["ACGT", "acgt"]);
50
+ expect(diffs).to.have.lengthOf(0);
51
+ });
52
+
53
+ it("detects a single-base insertion (gap in template)", () => {
54
+ // template: A-GT, query: ACGT → position 1 is an insertion
55
+ const diffs = findAlignmentDifferences(["A-GT", "ACGT"]);
56
+ const insertions = diffs.filter(d => d.type === "insertion");
57
+ expect(insertions).to.have.lengthOf(1);
58
+ expect(insertions[0].position).to.equal(1);
59
+ });
60
+
61
+ it("detects a multi-base insertion", () => {
62
+ // template: A---GT, query: ACCCGT
63
+ const diffs = findAlignmentDifferences(["A---GT", "ACCCGT"]);
64
+ const insertions = diffs.filter(d => d.type === "insertion");
65
+ expect(insertions).to.have.lengthOf(3);
66
+ expect(insertions.map(d => d.position)).to.deep.equal([1, 2, 3]);
67
+ });
68
+
69
+ it("detects a single-base deletion (gap in non-template)", () => {
70
+ // template: ACGT, query: A-GT → position 1 is a deletion
71
+ const diffs = findAlignmentDifferences(["ACGT", "A-GT"]);
72
+ const deletions = diffs.filter(d => d.type === "deletion");
73
+ expect(deletions).to.have.lengthOf(1);
74
+ expect(deletions[0].position).to.equal(1);
75
+ });
76
+
77
+ it("detects a multi-base deletion", () => {
78
+ // template: ACCCGT, query: A---GT
79
+ const diffs = findAlignmentDifferences(["ACCCGT", "A---GT"]);
80
+ const deletions = diffs.filter(d => d.type === "deletion");
81
+ expect(deletions).to.have.lengthOf(3);
82
+ expect(deletions.map(d => d.position)).to.deep.equal([1, 2, 3]);
83
+ });
84
+
85
+ it("detects leading non-aligned region", () => {
86
+ // query hasn't started yet at position 0-1
87
+ const diffs = findAlignmentDifferences(["ACGT", "--GT"]);
88
+ const gaps = diffs.filter(d => d.type === "gap");
89
+ expect(gaps).to.have.lengthOf(2);
90
+ expect(gaps.map(d => d.position)).to.deep.equal([0, 1]);
91
+ });
92
+
93
+ it("detects trailing non-aligned region", () => {
94
+ // query has ended after position 1
95
+ const diffs = findAlignmentDifferences(["ACGT", "AC--"]);
96
+ const gaps = diffs.filter(d => d.type === "gap");
97
+ expect(gaps).to.have.lengthOf(2);
98
+ expect(gaps.map(d => d.position)).to.deep.equal([2, 3]);
99
+ });
100
+
101
+ it("detects both leading and trailing non-aligned regions", () => {
102
+ const diffs = findAlignmentDifferences(["ACGTAC", "-CGTA-"]);
103
+ const gaps = diffs.filter(d => d.type === "gap");
104
+ expect(gaps.map(d => d.position)).to.deep.equal([0, 5]);
105
+ });
106
+
107
+ it("fully-gapped query returns all positions as gap", () => {
108
+ const diffs = findAlignmentDifferences(["ACGT", "----"]);
109
+ expect(diffs).to.have.lengthOf(4);
110
+ diffs.forEach(d => expect(d.type).to.equal("gap"));
111
+ });
112
+
113
+ it("handles mixed difference types in one alignment", () => {
114
+ // template: AACGT
115
+ // query: -ACGG
116
+ // pos 0: gap (leading '-' in query), pos 4: mismatch (T vs G)
117
+ const diffs = findAlignmentDifferences(["AACGT", "-ACGG"]);
118
+ expect(diffs.find(d => d.position === 0)?.type).to.equal("gap");
119
+ expect(diffs.find(d => d.position === 4)?.type).to.equal("mismatch");
120
+ });
121
+
122
+ it("handles multiple non-template tracks", () => {
123
+ // template: ACGT
124
+ // track1: ATGT → mismatch at pos 1
125
+ // track2: AC-T → deletion at pos 2
126
+ const diffs = findAlignmentDifferences(["ACGT", "ATGT", "AC-T"]);
127
+ expect(diffs.find(d => d.position === 1)?.type).to.equal("mismatch");
128
+ expect(diffs.find(d => d.position === 2)?.type).to.equal("deletion");
129
+ });
130
+
131
+ it("includes all track bases in returned diff object", () => {
132
+ // mismatch at position 1: template 'C' vs non-template 'T'
133
+ const diffs = findAlignmentDifferences(["ACGT", "ATGT"]);
134
+ expect(diffs[0].bases).to.have.lengthOf(2);
135
+ expect(diffs[0].bases[0]).to.equal("c"); // template at pos 1 (lowercased)
136
+ expect(diffs[0].bases[1]).to.equal("t"); // non-template at pos 1 (lowercased)
137
+ });
138
+ });
139
+
140
+ describe("groupConsecutiveDifferences", () => {
141
+ it("returns empty array for empty input", () => {
142
+ expect(groupConsecutiveDifferences([])).to.deep.equal([]);
143
+ });
144
+
145
+ it("does not group mismatches — each stays individual", () => {
146
+ // 4 consecutive mismatches → 4 separate entries
147
+ const diffs = findAlignmentDifferences(["AAAA", "TTTT"]);
148
+ const grouped = groupConsecutiveDifferences(diffs);
149
+ expect(grouped).to.have.lengthOf(4);
150
+ grouped.forEach((g, i) => {
151
+ expect(g.type).to.equal("mismatch");
152
+ expect(g.start).to.equal(i);
153
+ expect(g.end).to.equal(i);
154
+ });
155
+ });
156
+
157
+ it("groups consecutive deletions into one region", () => {
158
+ // template: ACCCGT, query: A---GT → 3 consecutive deletions
159
+ const diffs = findAlignmentDifferences(["ACCCGT", "A---GT"]);
160
+ const grouped = groupConsecutiveDifferences(diffs);
161
+ expect(grouped).to.have.lengthOf(1);
162
+ expect(grouped[0]).to.deep.include({ type: "deletion", start: 1, end: 3 });
163
+ });
164
+
165
+ it("groups consecutive insertions into one region", () => {
166
+ // template: A---GT, query: ACCCGT → 3 consecutive insertions
167
+ const diffs = findAlignmentDifferences(["A---GT", "ACCCGT"]);
168
+ const grouped = groupConsecutiveDifferences(diffs);
169
+ expect(grouped).to.have.lengthOf(1);
170
+ expect(grouped[0]).to.deep.include({ type: "insertion", start: 1, end: 3 });
171
+ });
172
+
173
+ it("groups consecutive gaps into one region", () => {
174
+ // leading 2-base gap + trailing 2-base gap
175
+ const diffs = findAlignmentDifferences(["ACGTAC", "--GT--"]);
176
+ const grouped = groupConsecutiveDifferences(diffs);
177
+ expect(grouped).to.have.lengthOf(2);
178
+ expect(grouped[0]).to.deep.include({ type: "gap", start: 0, end: 1 });
179
+ expect(grouped[1]).to.deep.include({ type: "gap", start: 4, end: 5 });
180
+ });
181
+
182
+ it("keeps non-consecutive same-type differences separate", () => {
183
+ // two deletions separated by a match; ends with base so trailing '-' is still aligned
184
+ // template: ACACA, query: A-A-A → deletions at pos 1 and 3, matches elsewhere
185
+ const diffs = findAlignmentDifferences(["ACACA", "A-A-A"]);
186
+ const grouped = groupConsecutiveDifferences(diffs);
187
+ expect(grouped).to.have.lengthOf(2);
188
+ expect(grouped[0]).to.deep.include({ type: "deletion", start: 1, end: 1 });
189
+ expect(grouped[1]).to.deep.include({ type: "deletion", start: 3, end: 3 });
190
+ });
191
+
192
+ it("handles mixed types — groups each type's runs independently", () => {
193
+ // template: A--CGT, query: ACCC-T
194
+ // pos 0: A vs A → match
195
+ // pos 1,2: template '-', query 'C','C' → insertions
196
+ // pos 3: C vs C → match
197
+ // pos 4: G vs '-' → deletion
198
+ // pos 5: T vs T → match
199
+ const diffs = findAlignmentDifferences(["A--CGT", "ACCC-T"]);
200
+ const grouped = groupConsecutiveDifferences(diffs);
201
+ const insertionGroups = grouped.filter(g => g.type === "insertion");
202
+ const deletionGroups = grouped.filter(g => g.type === "deletion");
203
+ expect(insertionGroups).to.have.lengthOf(1);
204
+ expect(insertionGroups[0]).to.deep.include({ start: 1, end: 2 });
205
+ expect(deletionGroups).to.have.lengthOf(1);
206
+ expect(deletionGroups[0]).to.deep.include({ start: 4, end: 4 });
207
+ });
208
+ });
@@ -30,6 +30,8 @@ import ReactDOM from "react-dom";
30
30
 
31
31
  import { NonReduxEnhancedLinearView } from "../LinearView";
32
32
  import Minimap, { getTrimmedRangesToDisplay } from "./Minimap";
33
+ import FindMismatches from "./Mismatches";
34
+ import AlignmentSearchBar from "./AlignmentSearchBar";
33
35
  import { compose, branch, renderComponent } from "recompose";
34
36
  import AlignmentVisibilityTool from "./AlignmentVisibilityTool";
35
37
  import * as alignmentActions from "../redux/alignments";
@@ -160,6 +162,12 @@ export const AlignmentView = props => {
160
162
  const [tempTrimBefore, setTempTrimBefore] = useState({});
161
163
  const [tempTrimAfter, setTempTrimAfter] = useState({});
162
164
  const [tempTrimmingCaret, setTempTrimmingCaret] = useState({});
165
+ const [searchMatchLayers, setSearchMatchLayers] = React.useState([]);
166
+ const [activeFilterType, setActiveFilterType] = useState("all");
167
+
168
+ const handleFilterChange = useCallback(({ activeFilter }) => {
169
+ setActiveFilterType(activeFilter);
170
+ }, []);
163
171
  const bindOutsideChangeHelper = useRef({});
164
172
  const alignmentHolder = useRef(null);
165
173
  const alignmentHolderTop = useRef(null);
@@ -1014,7 +1022,16 @@ export const AlignmentView = props => {
1014
1022
  chromatogramData
1015
1023
  })
1016
1024
  : linearViewOptions))}
1017
- additionalSelectionLayers={additionalSelectionLayers}
1025
+ additionalSelectionLayers={[
1026
+ ...(i !== 0
1027
+ ? (additionalSelectionLayers || []).filter(layer =>
1028
+ activeFilterType === "all"
1029
+ ? layer.differenceType !== "gap"
1030
+ : layer.differenceType === activeFilterType
1031
+ )
1032
+ : additionalSelectionLayers || []),
1033
+ ...(searchMatchLayers || [])
1034
+ ]}
1018
1035
  dimensions={{
1019
1036
  width: linearViewWidth
1020
1037
  }}
@@ -1614,7 +1631,7 @@ export const AlignmentView = props => {
1614
1631
  display: "flex",
1615
1632
  minHeight: "32px",
1616
1633
  width: "100%",
1617
- flexWrap: "nowrap",
1634
+ flexWrap: "wrap",
1618
1635
  flexDirection: "row",
1619
1636
  flex: "0 0 auto"
1620
1637
  }}
@@ -1747,6 +1764,16 @@ export const AlignmentView = props => {
1747
1764
  {...alignmentVisibilityToolOptions}
1748
1765
  />
1749
1766
  )}
1767
+ <AlignmentSearchBar
1768
+ alignmentTracks={alignmentTracks}
1769
+ id={id}
1770
+ setSearchMatchLayers={setSearchMatchLayers}
1771
+ />
1772
+ <FindMismatches
1773
+ alignmentJson={alignmentTracks}
1774
+ id={id}
1775
+ onFilterChange={handleFilterChange}
1776
+ />
1750
1777
  {additionalTopEl}
1751
1778
  {saveMessage && (
1752
1779
  <div
@@ -1857,6 +1884,7 @@ export const AlignmentView = props => {
1857
1884
  </>
1858
1885
  }
1859
1886
  alignmentTracks={alignmentTracks}
1887
+ activeFilterType={activeFilterType}
1860
1888
  dimensions={{
1861
1889
  width: Math.max(width, 10) || 10
1862
1890
  }}
@@ -1,3 +1,7 @@
1
+ .alignment-search-bar .bp3-input {
2
+ border-radius: 5px !important;
3
+ }
4
+
1
5
  .tg-pinch-helper {
2
6
  width: 100%;
3
7
  }
@@ -60,6 +64,10 @@
60
64
  /* .alignmentViewTrackContainer:hover .alignmentTrackNameDiv {
61
65
  opacity: 1 !important;
62
66
  } */
67
+ .ve-alignment-top-bar {
68
+ align-items: center;
69
+ }
70
+
63
71
  .ve-alignment-top-bar > * {
64
72
  overflow-wrap: normal;
65
73
  flex: 0 0 auto;
@@ -76,6 +84,82 @@
76
84
  .veAlignmentMismatch {
77
85
  opacity: 0.9;
78
86
  }
87
+
88
+ .veDiffNavigator {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 4px;
92
+ }
93
+
94
+ /* Menu item content layout */
95
+ .veDiffMenuItem-inner {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 6px;
99
+ }
100
+
101
+ /* Navigation pill — groups prev/counter/next into one unit */
102
+ .veDiffNav {
103
+ display: flex;
104
+ align-items: center;
105
+ border-radius: 3px;
106
+ background: rgba(92, 112, 128, 0.06);
107
+ border: 1px solid rgba(92, 112, 128, 0.18);
108
+ }
109
+
110
+ .veDiffNav-center {
111
+ display: flex;
112
+ align-items: baseline;
113
+ gap: 3px;
114
+ padding: 0 6px;
115
+ min-width: 64px;
116
+ justify-content: center;
117
+ }
118
+
119
+ .veDiffNav-fraction {
120
+ font-size: 11px;
121
+ font-variant-numeric: tabular-nums;
122
+ color: #5c7080;
123
+ line-height: 1;
124
+ }
125
+
126
+ .veDiffNav-sep {
127
+ margin: 0 1px;
128
+ opacity: 0.45;
129
+ }
130
+
131
+ /* Position number — monospace fits sequence coordinates */
132
+ .veDiffNav-pos {
133
+ font-size: 10px;
134
+ font-family: monospace;
135
+ font-variant-numeric: tabular-nums;
136
+ color: #a7b6c2;
137
+ line-height: 1;
138
+ }
139
+
140
+ .veDiffNav-empty {
141
+ font-size: 11px;
142
+ font-style: italic;
143
+ color: #a7b6c2;
144
+ padding: 0 4px;
145
+ }
146
+
147
+ .bp3-dark .veDiffNav {
148
+ background: rgba(167, 182, 194, 0.05);
149
+ border-color: rgba(167, 182, 194, 0.14);
150
+ }
151
+
152
+ .bp3-dark .veDiffNav-fraction {
153
+ color: #5c7080;
154
+ }
155
+
156
+ .bp3-dark .veDiffNav-pos {
157
+ color: #4f6272;
158
+ }
159
+
160
+ .bp3-dark .veDiffNav-empty {
161
+ color: #5c7080;
162
+ }
79
163
  .veRowItem:has(.rowViewTextContainer) .veAlignmentMismatch {
80
164
  opacity: 0.5;
81
165
  }
@@ -0,0 +1,30 @@
1
+ /* Copyright (C) 2018 TeselaGen Biotechnology, Inc. */
2
+
3
+ /**
4
+ * @typedef {Object} Mismatch
5
+ * @property {number} position
6
+ * @property {string[]} bases
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} FindMismatchesProps
11
+ * @property {Array<{
12
+ * alignmentData: {
13
+ * sequence: string
14
+ * }
15
+ * }>} alignmentJson
16
+ * @property {string} id
17
+ */
18
+
19
+ export function scrollToAlignmentSelection() {
20
+ const el = document.querySelector(".veCaret");
21
+ if (el) {
22
+ el.scrollIntoView({ inline: "center", block: "nearest" });
23
+ }
24
+ }
25
+
26
+ export function updateCaretPosition({ start, end }) {
27
+ if (window.updateAlignmentSelection) {
28
+ window.updateAlignmentSelection({ start, end });
29
+ }
30
+ }
@@ -5,11 +5,15 @@ import {
5
5
  aminoAcidShortNames
6
6
  } from "./calculateAminoAcidFrequency";
7
7
  import { Button } from "@blueprintjs/core";
8
+ import {
9
+ scrollToAlignmentSelection,
10
+ updateCaretPosition
11
+ } from "../AlignmentView/utils";
12
+ import { DataTable } from "@teselagen/ui";
8
13
 
9
14
  export default ({ properties, setProperties, style }) => {
10
15
  const sidebarRef = React.useRef(null);
11
16
  const [mismatchesCount, setMismatchesCount] = React.useState(0);
12
- const [mismatchesInRange, setMismatchesInRange] = React.useState(0);
13
17
 
14
18
  const { track, isOpen, selection, isPairwise } = properties;
15
19
 
@@ -33,6 +37,33 @@ export default ({ properties, setProperties, style }) => {
33
37
  return isPairwise ? tr.filter(m => m?.color === "red") : tr;
34
38
  }, [track, mismatchKey, isPairwise]);
35
39
 
40
+ const mismatchSchema = useMemo(
41
+ () => ({
42
+ fields: [
43
+ {
44
+ path: "start",
45
+ type: "number",
46
+ displayName: "Start",
47
+ render: val => val + 1
48
+ },
49
+ {
50
+ path: "end",
51
+ type: "number",
52
+ displayName: "End",
53
+ render: val => val + 1
54
+ }
55
+ ]
56
+ }),
57
+ []
58
+ );
59
+
60
+ const mismatchEntities = useMemo(() => {
61
+ return (trackMismatches || []).map((m, i) => ({
62
+ ...m,
63
+ id: i.toString()
64
+ }));
65
+ }, [trackMismatches]);
66
+
36
67
  useEffect(() => {
37
68
  if (!isOpen || sidebarRef.current === null || !track) {
38
69
  return;
@@ -54,24 +85,6 @@ export default ({ properties, setProperties, style }) => {
54
85
  });
55
86
 
56
87
  setMismatchesCount(mismatchCount);
57
- setMismatchesInRange(mismatchCount);
58
-
59
- if (selection && selection.start > -1 && selection.end > -1) {
60
- let count = 0;
61
-
62
- trackMismatches?.forEach(tm => {
63
- if (tm === null || tm.start === null || tm.end === null) {
64
- return;
65
- }
66
-
67
- const overlapStart = Math.max(tm.start, selection.start);
68
- const overlapEnd = Math.min(tm.end, selection.end);
69
- if (overlapEnd >= overlapStart) {
70
- count += overlapEnd - overlapStart + 1;
71
- }
72
- });
73
- setMismatchesInRange(count);
74
- }
75
88
  }, [isOpen, track, selection, trackMismatches]);
76
89
 
77
90
  const aminoFreq = useMemo(() => {
@@ -110,7 +123,7 @@ export default ({ properties, setProperties, style }) => {
110
123
  width: "100%"
111
124
  }}
112
125
  ></div>
113
- <h5>Track Properties</h5>
126
+ <HeaderItem title="Track Properties" />
114
127
 
115
128
  <div className="bp3-tab-panel">
116
129
  <RowItem item={name} title="Name" />
@@ -129,10 +142,6 @@ export default ({ properties, setProperties, style }) => {
129
142
  />
130
143
  </>
131
144
  )}
132
- <RowItem
133
- item={`${mismatchesInRange}/${mismatchesCount}`}
134
- title="Mismatches"
135
- />
136
145
  <RowItem
137
146
  item={
138
147
  selection && selection.start > -1 ? (
@@ -145,8 +154,38 @@ export default ({ properties, setProperties, style }) => {
145
154
  }
146
155
  title="Region"
147
156
  />
157
+ <HeaderItem title={`Mismatches (${mismatchesCount})`} />
158
+
159
+ {trackMismatches && trackMismatches.length > 0 && (
160
+ <div
161
+ style={{
162
+ margin: "0px 10px"
163
+ }}
164
+ >
165
+ <DataTable
166
+ formName="mismatchesTable"
167
+ isSimple
168
+ noHeader
169
+ noFooter
170
+ withSearch={false}
171
+ noPadding
172
+ compact
173
+ maxHeight={150}
174
+ entities={mismatchEntities}
175
+ schema={mismatchSchema}
176
+ onRowClick={(e, row) => {
177
+ updateCaretPosition({ start: row.start, end: row.end });
178
+ setTimeout(() => {
179
+ scrollToAlignmentSelection();
180
+ }, 0);
181
+ }}
182
+ />
183
+ </div>
184
+ )}
148
185
  </div>
149
- <h5>{isProtein ? "Amino Acid" : "Base Pair"} Frequencies</h5>
186
+ <HeaderItem
187
+ title={`${isProtein ? "Amino Acid" : "Base Pair"} Frequencies`}
188
+ />
150
189
  <div className="sidebar-table">
151
190
  <div className="sidebar-row">
152
191
  <div className="sidebar-cell">
@@ -225,15 +264,37 @@ export default ({ properties, setProperties, style }) => {
225
264
  };
226
265
 
227
266
  function RowItem({ item, title, units }) {
228
- if (!item) return;
229
-
267
+ // Skip rendering when the value is absent (null or undefined),
268
+ // but still allow 0 and other falsy-but-valid values.
269
+ if (item == null) {
270
+ return null;
271
+ }
230
272
  const propertyClass = title.split(" ").join("-").toLowerCase();
231
273
  return (
232
274
  <div className={`ve-flex-row property-${propertyClass}`}>
233
- <div className="ve-column-left">{title}</div>
275
+ <div style={{ fontWeight: "bold" }} className="ve-column-left">
276
+ {title}
277
+ </div>
234
278
  <div className="ve-column-right">
235
279
  {item} {units ?? ""}
236
280
  </div>
237
281
  </div>
238
282
  );
239
283
  }
284
+
285
+ const HeaderItem = ({ title }) => {
286
+ return (
287
+ <h5
288
+ style={{
289
+ margin: 0,
290
+ fontSize: 15,
291
+ fontWeight: "bold",
292
+ textAlign: "center",
293
+ padding: "5px 0",
294
+ borderBottom: "1px solid #f1f1f1"
295
+ }}
296
+ >
297
+ {title}
298
+ </h5>
299
+ );
300
+ };