Skip to content

Commit 036f12f

Browse files
committed
feat: search in collapsed nodes
1 parent 8b59022 commit 036f12f

File tree

13 files changed

+503
-21
lines changed

13 files changed

+503
-21
lines changed

src/ReactUnipika/ReactUnipika.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface ReactUnipikaProps {
2222
toolbarStickyTop?: number;
2323
renderToolbar?: (props: ToolbarProps) => React.ReactNode;
2424
collapseIconType?: CollapseIconType;
25+
searchInCollapsed?: boolean;
2526
}
2627

2728
const defaultUnipikaSettings = {
@@ -45,6 +46,7 @@ export function ReactUnipika({
4546
toolbarStickyTop = 0,
4647
renderToolbar,
4748
collapseIconType,
49+
searchInCollapsed,
4850
}: ReactUnipikaProps) {
4951
const convertedValue = React.useMemo(() => {
5052
// TODO: fix me later
@@ -98,6 +100,7 @@ export function ReactUnipika({
98100
toolbarStickyTop={toolbarStickyTop}
99101
renderToolbar={renderToolbar}
100102
collapseIconType={collapseIconType}
103+
searchInCollapsed={searchInCollapsed}
101104
/>
102105
) : (
103106
<pre

src/ReactUnipika/__stories__/ReactUnipika.stories.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@ export const WithContentAbove: StoryObj<ReactUnipikaProps> = {
3737
);
3838
},
3939
};
40+
41+
export const SearchInCollapsed: StoryObj<ReactUnipikaProps> = {
42+
args: {
43+
value: data,
44+
searchInCollapsed: true,
45+
},
46+
};

src/ReactUnipika/__tests__/ReactUnipika.visual.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,47 @@ test('ReactUnipika: with content above', async ({mount, expectScreenshot, page})
7575

7676
await expectScreenshot({component: page});
7777
});
78+
79+
test('ReactUnipika: search in collapsed - collapsed tree with search', async ({
80+
mount,
81+
expectScreenshot,
82+
page,
83+
}) => {
84+
await mount(<Stories.SearchInCollapsed />, {width: 1280});
85+
86+
// Collapse all
87+
await page.getByTestId('qa:structuredyson:collapse-all').click();
88+
89+
// Enter search term
90+
await page.getByTestId('qa:structuredyson:search').locator('input').fill('attr');
91+
92+
// Wait for search to complete
93+
await page.waitForTimeout(300);
94+
95+
await expectScreenshot({component: page});
96+
});
97+
98+
test('ReactUnipika: search in collapsed - navigate forward', async ({
99+
mount,
100+
expectScreenshot,
101+
page,
102+
}) => {
103+
await mount(<Stories.SearchInCollapsed />, {width: 1280});
104+
105+
// Collapse all
106+
await page.getByTestId('qa:structuredyson:collapse-all').click();
107+
108+
// Enter search term
109+
await page.getByTestId('qa:structuredyson:search').locator('input').fill('attr');
110+
111+
// Wait for search to complete
112+
await page.waitForTimeout(300);
113+
114+
// Navigate forward (should expand first collapsed node with match)
115+
await page.getByTestId('qa:structuredyson:search:next').click();
116+
117+
// Wait for expansion and navigation
118+
await page.waitForTimeout(300);
119+
120+
await expectScreenshot({component: page});
121+
});

src/StructuredYson/Cell.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,11 @@
4646
padding-right: 2px;
4747
}
4848
}
49+
50+
&__hidden-matches {
51+
margin-left: var(--g-spacing-1);
52+
font-size: 0.85em;
53+
color: var(--g-color-text-warning);
54+
font-style: italic;
55+
}
4956
}

src/StructuredYson/Cell.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {MultiHighlightedText, MultiHighlightedTextProps} from '../HighlightedTex
1313
import {ClickableText} from '../ClickableText/ClickableText';
1414

1515
import {cn} from '../utils/classname';
16+
import i18n from './i18n';
1617

1718
import './Cell.scss';
1819

@@ -62,7 +63,18 @@ function getLevelOffsetSpaces(level: number) {
6263

6364
export function Cell(props: CellProps) {
6465
const {
65-
row: {level, open, close, key, value, hasDelimiter, path, collapsed, isAfterAttributes},
66+
row: {
67+
level,
68+
open,
69+
close,
70+
key,
71+
value,
72+
hasDelimiter,
73+
path,
74+
collapsed,
75+
isAfterAttributes,
76+
hiddenMatches,
77+
},
6678
settings,
6779
yson,
6880
onToggleCollapse,
@@ -117,6 +129,11 @@ export function Cell(props: CellProps) {
117129
{collapsed && <span className={'unipika'}>...</span>}
118130
{close && <OpenClose type={close} yson={yson} settings={settings} close />}
119131
{hasDelimiter && <SlaveText text={yson ? ';' : ','} />}
132+
{collapsed && hiddenMatches && (
133+
<span className={block('hidden-matches')}>
134+
({i18n('context_matches-count', {count: hiddenMatches})})
135+
</span>
136+
)}
120137
</div>
121138
);
122139
}

src/StructuredYson/StructuredYson.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
white-space: nowrap;
1616
}
1717

18+
&__hidden-counter {
19+
color: var(--g-color-text-warning);
20+
font-size: 0.9em;
21+
}
22+
1823
&__match-btn {
1924
margin-left: -1px;
2025
}

src/StructuredYson/StructuredYson.tsx

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface Props {
3939
toolbarStickyTop?: number;
4040
renderToolbar?: (props: ToolbarProps) => React.ReactNode;
4141
collapseIconType?: CollapseIconType;
42+
searchInCollapsed?: boolean;
4243
}
4344

4445
interface State {
@@ -50,6 +51,7 @@ interface State {
5051
filter: string;
5152
matchIndex: number;
5253
matchedRows: Array<number>;
54+
allMatchPaths: Array<string>;
5355
fullValue?: {
5456
value: UnipikaFlattenTreeItem['value'];
5557
searchInfo?: SearchInfo;
@@ -61,27 +63,49 @@ function calculateState(
6163
collapsedState: CollapsedState,
6264
filter: string,
6365
settings: UnipikaSettings,
66+
searchInCollapsed?: boolean,
6467
) {
6568
const flattenResult = flattenUnipika(value, {
6669
isJson: settings.format !== 'yson',
6770
collapsedState: collapsedState,
6871
filter,
6972
settings: settings,
73+
searchInCollapsed,
7074
});
7175

76+
const allMatchPaths = flattenResult.allMatchPaths || [];
77+
// Calculate hiddenMatches for collapsed nodes if searchInCollapsed is enabled
78+
if (searchInCollapsed && allMatchPaths.length > 0) {
79+
// Count matches that are inside or at collapsed nodes
80+
flattenResult.data.forEach((item) => {
81+
if (item.collapsed && item.path) {
82+
const prefix = item.path + '/';
83+
const count = allMatchPaths.filter((matchPath) => {
84+
// Match if path is exactly the item path (match at the node itself)
85+
// or starts with prefix (match inside the node)
86+
return matchPath === item.path || matchPath.startsWith(prefix);
87+
}).length;
88+
if (count > 0) {
89+
item.hiddenMatches = count;
90+
}
91+
}
92+
});
93+
}
94+
7295
return Object.assign(
7396
{},
7497
{
7598
flattenResult,
7699
matchedRows: Object.keys(flattenResult.searchIndex).map(Number),
100+
allMatchPaths,
77101
},
78102
);
79103
}
80104

81105
export class StructuredYson extends React.PureComponent<Props, State> {
82106
static getDerivedStateFromProps(props: Props, state: State) {
83107
const {value: prevValue, settings: prevSettings, yson: prevYson} = state;
84-
const {value, settings} = props;
108+
const {value, settings, searchInCollapsed} = props;
85109
const res: Partial<State> = {};
86110
const yson = settings.format === 'yson';
87111
if (prevSettings !== settings || yson !== prevYson) {
@@ -93,7 +117,13 @@ export class StructuredYson extends React.PureComponent<Props, State> {
93117
if (prevValue !== value || !isEmpty_(res)) {
94118
Object.assign<Partial<State>, Partial<State>>(res, {
95119
value,
96-
...calculateState(value, state.collapsedState, state.filter, settings),
120+
...calculateState(
121+
value,
122+
state.collapsedState,
123+
state.filter,
124+
settings,
125+
searchInCollapsed,
126+
),
97127
});
98128
}
99129
return isEmpty_(res) ? null : res;
@@ -109,6 +139,7 @@ export class StructuredYson extends React.PureComponent<Props, State> {
109139
filter: '',
110140
matchIndex: -1,
111141
matchedRows: [],
142+
allMatchPaths: [],
112143
};
113144

114145
tableRef: TableProps['scrollToRef'] = React.createRef();
@@ -131,6 +162,7 @@ export class StructuredYson extends React.PureComponent<Props, State> {
131162
cb?: () => void,
132163
) {
133164
const {value, settings} = this.state;
165+
const {searchInCollapsed} = this.props;
134166
const {
135167
collapsedState = this.state.collapsedState,
136168
matchIndex = this.state.matchIndex,
@@ -142,7 +174,7 @@ export class StructuredYson extends React.PureComponent<Props, State> {
142174
collapsedState,
143175
filter,
144176
matchIndex,
145-
...calculateState(value, collapsedState, filter, settings),
177+
...calculateState(value, collapsedState, filter, settings, searchInCollapsed),
146178
},
147179
cb,
148180
);
@@ -199,23 +231,62 @@ export class StructuredYson extends React.PureComponent<Props, State> {
199231
});
200232
};
201233

234+
findCollapsedParent = (matchPath: string, collapsedState: CollapsedState): string | null => {
235+
const pathParts = matchPath.split('/');
236+
for (let i = 1; i <= pathParts.length; i++) {
237+
const checkPath = pathParts.slice(0, i).join('/');
238+
if (collapsedState[checkPath]) {
239+
return checkPath;
240+
}
241+
}
242+
return null;
243+
};
244+
202245
onNextMatch = (_event: unknown, diff = 1) => {
203-
const {matchIndex, matchedRows} = this.state;
204-
if (isEmpty_(matchedRows)) {
246+
const {matchIndex, matchedRows, allMatchPaths, collapsedState} = this.state;
247+
248+
const totalMatches = allMatchPaths?.length || 0;
249+
250+
if (totalMatches === 0) {
205251
return;
206252
}
207253

208-
let index = (matchIndex + diff) % matchedRows.length;
209-
if (index < 0) {
210-
index = matchedRows.length + index;
211-
}
254+
// Calculate next index in total matches
255+
let nextTotalIndex = matchIndex + diff;
256+
nextTotalIndex = ((nextTotalIndex % totalMatches) + totalMatches) % totalMatches;
257+
258+
const targetMatchPath = allMatchPaths[nextTotalIndex];
259+
260+
const collapsedParent = this.findCollapsedParent(targetMatchPath, collapsedState);
261+
262+
// If target is not hidden, it's visible - navigate to it
263+
if (collapsedParent === null) {
264+
// Count how many visible matches are before our target
265+
let visibleMatchCount = 0;
266+
for (let i = 0; i < nextTotalIndex; i++) {
267+
if (this.findCollapsedParent(allMatchPaths[i], collapsedState) === null) {
268+
visibleMatchCount++;
269+
}
270+
}
212271

213-
if (index !== matchIndex) {
214-
this.setState({matchIndex: index});
272+
// Navigate to the visible match
273+
if (visibleMatchCount < matchedRows.length) {
274+
this.setState({matchIndex: nextTotalIndex});
275+
this.tableRef.current?.scrollToIndex(matchedRows[visibleMatchCount]);
276+
this.searchRef.current?.focus();
277+
}
278+
return;
215279
}
216280

217-
this.tableRef.current?.scrollToIndex(matchedRows[index]);
218-
this.searchRef.current?.focus();
281+
// Target is hidden - expand the collapsed parent
282+
const newCollapsedState = {...collapsedState};
283+
delete newCollapsedState[collapsedParent];
284+
285+
// Recalculate state with new collapsed state
286+
this.updateState({collapsedState: newCollapsedState, matchIndex: nextTotalIndex}, () => {
287+
// Retry navigation to the same target
288+
this.onNextMatch(null, 0);
289+
});
219290
};
220291

221292
onPrevMatch = () => {
@@ -233,7 +304,7 @@ export class StructuredYson extends React.PureComponent<Props, State> {
233304
};
234305

235306
renderToolbar(className?: string) {
236-
const {matchIndex, matchedRows, filter, collapsedState} = this.state;
307+
const {matchIndex, matchedRows, filter, collapsedState, allMatchPaths} = this.state;
237308
const {extraTools, renderToolbar} = this.props;
238309

239310
// Calculate if there are any collapsed nodes
@@ -254,6 +325,7 @@ export class StructuredYson extends React.PureComponent<Props, State> {
254325
filter={filter}
255326
matchIndex={matchIndex}
256327
matchedRows={matchedRows}
328+
allMatchPaths={allMatchPaths}
257329
extraTools={extraTools}
258330
onExpandAll={this.onExpandAll}
259331
onCollapseAll={this.onCollapseAll}

0 commit comments

Comments
 (0)