mirror of
https://github.com/foss42/apidash.git
synced 2025-12-02 18:57:05 +08:00
feat: implement search highlighting across terminal tiles and expandable sections
This commit is contained in:
@@ -120,6 +120,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = filtered[filtered.length - 1 - i];
|
||||
final searchQuery = _searchCtrl.text.trim();
|
||||
String requestName = '';
|
||||
if (e.source == TerminalSource.js &&
|
||||
e.requestId != null) {
|
||||
@@ -140,6 +141,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
return JsLogTile(
|
||||
entry: e,
|
||||
showTimestamp: _showTimestamps,
|
||||
searchQuery: searchQuery,
|
||||
requestName:
|
||||
requestName.isNotEmpty ? requestName : null,
|
||||
);
|
||||
@@ -147,6 +149,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
return NetworkLogTile(
|
||||
entry: e,
|
||||
showTimestamp: _showTimestamps,
|
||||
searchQuery: searchQuery,
|
||||
requestName:
|
||||
requestName.isNotEmpty ? requestName : null,
|
||||
);
|
||||
@@ -154,6 +157,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
return SystemLogTile(
|
||||
entry: e,
|
||||
showTimestamp: _showTimestamps,
|
||||
searchQuery: searchQuery,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExpandableSection extends StatefulWidget {
|
||||
const ExpandableSection(
|
||||
{super.key, required this.title, required this.child});
|
||||
const ExpandableSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.initiallyOpen = false,
|
||||
this.forceOpen,
|
||||
this.highlightQuery,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final bool initiallyOpen;
|
||||
final bool? forceOpen;
|
||||
final String? highlightQuery;
|
||||
|
||||
@override
|
||||
State<ExpandableSection> createState() => _ExpandableSectionState();
|
||||
}
|
||||
|
||||
class _ExpandableSectionState extends State<ExpandableSection> {
|
||||
bool _open = false;
|
||||
late bool _open = widget.initiallyOpen;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExpandableSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// If forceOpen toggles from null to a value or changes, reflect it
|
||||
if (widget.forceOpen != null && widget.forceOpen != oldWidget.forceOpen) {
|
||||
_open = widget.forceOpen!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,25 +40,26 @@ class _ExpandableSectionState extends State<ExpandableSection> {
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
onTap: () {
|
||||
if (widget.forceOpen == null) {
|
||||
setState(() => _open = !_open);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildTitle(context)),
|
||||
Icon(
|
||||
_open ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
||||
(widget.forceOpen ?? _open)
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_open)
|
||||
if (widget.forceOpen ?? _open)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: widget.child,
|
||||
@@ -48,4 +67,34 @@ class _ExpandableSectionState extends State<ExpandableSection> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
final q = widget.highlightQuery?.trim();
|
||||
final style = Theme.of(context).textTheme.titleSmall;
|
||||
if (q == null || q.isEmpty) {
|
||||
return Text(widget.title, style: style);
|
||||
}
|
||||
final lower = widget.title.toLowerCase();
|
||||
final lowerQ = q.toLowerCase();
|
||||
final spans = <TextSpan>[];
|
||||
int start = 0;
|
||||
int idx;
|
||||
final hlStyle = style?.copyWith(
|
||||
background: Paint()
|
||||
..color = Theme.of(context)
|
||||
.colorScheme
|
||||
.tertiaryContainer
|
||||
.withValues(alpha: 0.8),
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
while ((idx = lower.indexOf(lowerQ, start)) != -1) {
|
||||
if (idx > start) spans.add(TextSpan(text: widget.title.substring(start, idx), style: style));
|
||||
spans.add(TextSpan(text: widget.title.substring(idx, idx + lowerQ.length), style: hlStyle));
|
||||
start = idx + lowerQ.length;
|
||||
}
|
||||
if (start < widget.title.length) {
|
||||
spans.add(TextSpan(text: widget.title.substring(start), style: style));
|
||||
}
|
||||
return RichText(text: TextSpan(children: spans, style: style));
|
||||
}
|
||||
}
|
||||
|
||||
93
lib/widgets/highlight_text.dart
Normal file
93
lib/widgets/highlight_text.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget to highlight occurrences of [query] inside [text].
|
||||
/// Case-insensitive; all matches highlighted.
|
||||
class HighlightedSelectableText extends StatelessWidget {
|
||||
const HighlightedSelectableText({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.query,
|
||||
this.style,
|
||||
});
|
||||
final String text;
|
||||
final String? query;
|
||||
final TextStyle? style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final q = query?.trim();
|
||||
if (q == null || q.isEmpty) {
|
||||
return SelectableText(text, style: style);
|
||||
}
|
||||
final lower = text.toLowerCase();
|
||||
final lowerQ = q.toLowerCase();
|
||||
final spans = <TextSpan>[];
|
||||
int start = 0;
|
||||
int idx;
|
||||
final base = style ?? DefaultTextStyle.of(context).style;
|
||||
final bgColor = Theme.of(context).colorScheme.secondaryContainer;
|
||||
final highlightStyle = base.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
background: Paint()
|
||||
..color = bgColor.withValues(alpha: 0.85)
|
||||
..style = PaintingStyle.fill,
|
||||
color: base.color,
|
||||
);
|
||||
while ((idx = lower.indexOf(lowerQ, start)) != -1) {
|
||||
if (idx > start) {
|
||||
spans.add(TextSpan(text: text.substring(start, idx), style: base));
|
||||
}
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(idx, idx + lowerQ.length),
|
||||
style: highlightStyle,
|
||||
));
|
||||
start = idx + lowerQ.length;
|
||||
}
|
||||
if (start < text.length) {
|
||||
spans.add(TextSpan(text: text.substring(start), style: base));
|
||||
}
|
||||
return SelectableText.rich(TextSpan(children: spans));
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to produce highlighted spans for inline RichText content.
|
||||
List<InlineSpan> buildHighlightedSpans(
|
||||
String text,
|
||||
BuildContext context,
|
||||
String? query, {
|
||||
TextStyle? baseStyle,
|
||||
}) {
|
||||
final q = query?.trim();
|
||||
if (q == null || q.isEmpty) {
|
||||
return [TextSpan(text: text, style: baseStyle)];
|
||||
}
|
||||
final lower = text.toLowerCase();
|
||||
final lowerQ = q.toLowerCase();
|
||||
final spans = <InlineSpan>[];
|
||||
int start = 0;
|
||||
int idx;
|
||||
final base = baseStyle ?? DefaultTextStyle.of(context).style;
|
||||
final highlightStyle = base.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
background: Paint()
|
||||
..color = Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer
|
||||
.withValues(alpha: 0.85)
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
while ((idx = lower.indexOf(lowerQ, start)) != -1) {
|
||||
if (idx > start) {
|
||||
spans.add(TextSpan(text: text.substring(start, idx), style: base));
|
||||
}
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(idx, idx + lowerQ.length),
|
||||
style: highlightStyle,
|
||||
));
|
||||
start = idx + lowerQ.length;
|
||||
}
|
||||
if (start < text.length) {
|
||||
spans.add(TextSpan(text: text.substring(start), style: base));
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
@@ -5,12 +5,18 @@ import '../consts.dart';
|
||||
import '../models/terminal/models.dart';
|
||||
import '../utils/ui_utils.dart';
|
||||
import 'expandable_section.dart';
|
||||
import 'highlight_text.dart';
|
||||
|
||||
class SystemLogTile extends StatelessWidget {
|
||||
const SystemLogTile(
|
||||
{super.key, required this.entry, this.showTimestamp = false});
|
||||
const SystemLogTile({
|
||||
super.key,
|
||||
required this.entry,
|
||||
this.showTimestamp = false,
|
||||
this.searchQuery,
|
||||
});
|
||||
final TerminalEntry entry;
|
||||
final bool showTimestamp;
|
||||
final String? searchQuery;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(entry.system != null, 'System tile requires SystemLogData');
|
||||
@@ -55,10 +61,18 @@ class SystemLogTile extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('[${s.category}] ${s.message}', style: titleStyle),
|
||||
HighlightedSelectableText(
|
||||
text: '[${s.category}] ${s.message}',
|
||||
style: titleStyle,
|
||||
query: searchQuery,
|
||||
),
|
||||
if (s.stack != null && s.stack!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(s.stack!, style: subStyle),
|
||||
HighlightedSelectableText(
|
||||
text: s.stack!,
|
||||
style: subStyle,
|
||||
query: searchQuery,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -70,14 +84,17 @@ class SystemLogTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
class JsLogTile extends StatelessWidget {
|
||||
const JsLogTile(
|
||||
{super.key,
|
||||
const JsLogTile({
|
||||
super.key,
|
||||
required this.entry,
|
||||
this.showTimestamp = false,
|
||||
this.requestName});
|
||||
this.requestName,
|
||||
this.searchQuery,
|
||||
});
|
||||
final TerminalEntry entry;
|
||||
final bool showTimestamp;
|
||||
final String? requestName;
|
||||
final String? searchQuery;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(entry.js != null, 'JS tile requires JsLogData');
|
||||
@@ -130,9 +147,10 @@ class JsLogTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
bodyText,
|
||||
child: HighlightedSelectableText(
|
||||
text: bodyText,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
query: searchQuery,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -142,20 +160,55 @@ class JsLogTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
class NetworkLogTile extends StatefulWidget {
|
||||
const NetworkLogTile(
|
||||
{super.key,
|
||||
const NetworkLogTile({
|
||||
super.key,
|
||||
required this.entry,
|
||||
this.showTimestamp = false,
|
||||
this.requestName});
|
||||
this.requestName,
|
||||
this.searchQuery,
|
||||
});
|
||||
final TerminalEntry entry;
|
||||
final bool showTimestamp;
|
||||
final String? requestName;
|
||||
final String? searchQuery;
|
||||
@override
|
||||
State<NetworkLogTile> createState() => _NetworkLogTileState();
|
||||
}
|
||||
|
||||
class _NetworkLogTileState extends State<NetworkLogTile> {
|
||||
bool _expanded = false;
|
||||
String? _lastQuery;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NetworkLogTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Auto-expand if a new non-empty search query matches hidden detail content
|
||||
final q = widget.searchQuery?.trim();
|
||||
if (q != null && q.isNotEmpty && q != _lastQuery) {
|
||||
if (!_expanded && _networkDetailsContainQuery(q)) {
|
||||
setState(() => _expanded = true);
|
||||
}
|
||||
}
|
||||
_lastQuery = q;
|
||||
}
|
||||
|
||||
bool _networkDetailsContainQuery(String q) {
|
||||
final n = widget.entry.network!;
|
||||
final lowerQ = q.toLowerCase();
|
||||
bool inMap(Map<String, String>? m) =>
|
||||
m != null &&
|
||||
m.entries.any((e) =>
|
||||
e.key.toLowerCase().contains(lowerQ) ||
|
||||
e.value.toLowerCase().contains(lowerQ));
|
||||
bool inText(String? t) =>
|
||||
t != null && t.toLowerCase().contains(lowerQ) && t.isNotEmpty;
|
||||
return inMap(n.requestHeaders) ||
|
||||
inMap(n.responseHeaders) ||
|
||||
inText(n.requestBodyPreview) ||
|
||||
inText(n.responseBodyPreview) ||
|
||||
inText(n.errorMessage);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final n = widget.entry.network!;
|
||||
@@ -195,12 +248,27 @@ class _NetworkLogTileState extends State<NetworkLogTile> {
|
||||
children: [
|
||||
if (widget.requestName != null &&
|
||||
widget.requestName!.isNotEmpty) ...[
|
||||
TextSpan(text: '[${widget.requestName}] '),
|
||||
...buildHighlightedSpans(
|
||||
'[${widget.requestName}]',
|
||||
context,
|
||||
widget.searchQuery,
|
||||
baseStyle: bodyStyle,
|
||||
),
|
||||
const TextSpan(text: ' '),
|
||||
],
|
||||
TextSpan(
|
||||
text: n.method.name.toUpperCase(),
|
||||
style: methodStyle),
|
||||
TextSpan(text: ' ${n.url}'),
|
||||
...buildHighlightedSpans(
|
||||
n.method.name.toUpperCase(),
|
||||
context,
|
||||
widget.searchQuery,
|
||||
baseStyle: methodStyle,
|
||||
),
|
||||
const TextSpan(text: ' '),
|
||||
...buildHighlightedSpans(
|
||||
n.url,
|
||||
context,
|
||||
widget.searchQuery,
|
||||
baseStyle: bodyStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -219,43 +287,81 @@ class _NetworkLogTileState extends State<NetworkLogTile> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_expanded) NetworkDetails(n: n),
|
||||
if (_expanded) NetworkDetails(n: n, searchQuery: widget.searchQuery),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkDetails extends StatelessWidget {
|
||||
const NetworkDetails({super.key, required this.n});
|
||||
const NetworkDetails({super.key, required this.n, this.searchQuery});
|
||||
final NetworkLogData n;
|
||||
final String? searchQuery;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tiles = <Widget>[
|
||||
ExpandableSection(
|
||||
title: 'Network',
|
||||
child: _kvBody({
|
||||
final q = searchQuery?.trim();
|
||||
bool contains(String? text) =>
|
||||
q != null &&
|
||||
q.isNotEmpty &&
|
||||
text != null &&
|
||||
text.toLowerCase().contains(q.toLowerCase());
|
||||
bool mapContains(Map<String, String>? m) =>
|
||||
q != null &&
|
||||
q.isNotEmpty &&
|
||||
m != null &&
|
||||
m.entries.any((e) =>
|
||||
e.key.toLowerCase().contains(q.toLowerCase()) ||
|
||||
e.value.toLowerCase().contains(q.toLowerCase()));
|
||||
|
||||
final networkBodyMap = {
|
||||
'API Type': n.apiType.name,
|
||||
'Phase': n.phase.name,
|
||||
if (n.isStreaming) 'Streaming': 'true',
|
||||
if (n.sentAt != null) 'Sent': n.sentAt!.toIso8601String(),
|
||||
if (n.completedAt != null)
|
||||
'Completed': n.completedAt!.toIso8601String(),
|
||||
if (n.duration != null)
|
||||
'Duration': '${n.duration!.inMilliseconds} ms',
|
||||
if (n.completedAt != null) 'Completed': n.completedAt!.toIso8601String(),
|
||||
if (n.duration != null) 'Duration': '${n.duration!.inMilliseconds} ms',
|
||||
'URL': n.url,
|
||||
'Method': n.method.name.toUpperCase(),
|
||||
if (n.responseStatus != null) 'Status': '${n.responseStatus}',
|
||||
if (n.errorMessage != null) 'Error': n.errorMessage!,
|
||||
}),
|
||||
};
|
||||
final networkSectionHasMatch = q != null &&
|
||||
q.isNotEmpty &&
|
||||
networkBodyMap.entries.any((e) =>
|
||||
e.key.toLowerCase().contains(q.toLowerCase()) ||
|
||||
e.value.toLowerCase().contains(q.toLowerCase()));
|
||||
|
||||
final tiles = <Widget>[
|
||||
ExpandableSection(
|
||||
title: 'Network',
|
||||
forceOpen: networkSectionHasMatch ? true : null,
|
||||
highlightQuery: searchQuery,
|
||||
child: _kvBody(networkBodyMap, query: searchQuery),
|
||||
),
|
||||
ExpandableSection(
|
||||
title: 'Request Headers', child: _mapBody(n.requestHeaders)),
|
||||
title: 'Request Headers',
|
||||
forceOpen: mapContains(n.requestHeaders) ? true : null,
|
||||
highlightQuery: searchQuery,
|
||||
child: _mapBody(n.requestHeaders, query: searchQuery),
|
||||
),
|
||||
ExpandableSection(
|
||||
title: 'Request Body', child: _textBody(n.requestBodyPreview)),
|
||||
title: 'Request Body',
|
||||
forceOpen: contains(n.requestBodyPreview) ? true : null,
|
||||
highlightQuery: searchQuery,
|
||||
child: _textBody(n.requestBodyPreview, query: searchQuery),
|
||||
),
|
||||
ExpandableSection(
|
||||
title: 'Response Headers', child: _mapBody(n.responseHeaders)),
|
||||
title: 'Response Headers',
|
||||
forceOpen: mapContains(n.responseHeaders) ? true : null,
|
||||
highlightQuery: searchQuery,
|
||||
child: _mapBody(n.responseHeaders, query: searchQuery),
|
||||
),
|
||||
ExpandableSection(
|
||||
title: 'Response Body', child: _textBody(n.responseBodyPreview)),
|
||||
title: 'Response Body',
|
||||
forceOpen: contains(n.responseBodyPreview) ? true : null,
|
||||
highlightQuery: searchQuery,
|
||||
child: _textBody(n.responseBodyPreview, query: searchQuery),
|
||||
),
|
||||
];
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
@@ -271,21 +377,22 @@ class NetworkDetails extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _textBody(String? text) {
|
||||
return SelectableText(text == null || text.isEmpty ? '(empty)' : text);
|
||||
Widget _textBody(String? text, {String? query}) {
|
||||
final value = text == null || text.isEmpty ? '(empty)' : text;
|
||||
return HighlightedSelectableText(text: value, query: query);
|
||||
}
|
||||
|
||||
Widget _mapBody(Map<String, String>? map) {
|
||||
Widget _mapBody(Map<String, String>? map, {String? query}) {
|
||||
if (map == null || map.isEmpty) {
|
||||
return const SelectableText('(none)');
|
||||
return const HighlightedSelectableText(text: '(none)');
|
||||
}
|
||||
final lines = map.entries.map((e) => '${e.key}: ${e.value}').join('\n');
|
||||
return SelectableText(lines);
|
||||
return HighlightedSelectableText(text: lines, query: query);
|
||||
}
|
||||
|
||||
Widget _kvBody(Map<String, String> map) {
|
||||
Widget _kvBody(Map<String, String> map, {String? query}) {
|
||||
final lines = map.entries.map((e) => '${e.key}: ${e.value}').join('\n');
|
||||
return SelectableText(lines);
|
||||
return HighlightedSelectableText(text: lines, query: query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,5 +401,6 @@ String _formatTs(DateTime ts) {
|
||||
final h = ts.hour.toString().padLeft(2, '0');
|
||||
final m = ts.minute.toString().padLeft(2, '0');
|
||||
final s = ts.second.toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
final ms = ts.millisecond.toString().padLeft(2, '0');
|
||||
return '$h:$m:$s.$ms';
|
||||
}
|
||||
|
||||
@@ -57,8 +57,13 @@ void main() {
|
||||
expect(find.byType(ListView), findsOneWidget);
|
||||
// There are separators, but count entries by specific content
|
||||
// JS tile renders its body as SelectableText containing args/context
|
||||
final alphaText = find.byWidgetPredicate(
|
||||
(w) => w is SelectableText && (w.data?.contains('alpha') ?? false));
|
||||
final alphaText = find.byWidgetPredicate((w) {
|
||||
if (w is SelectableText) {
|
||||
final text = w.data ?? w.textSpan?.toPlainText() ?? '';
|
||||
return text.contains('alpha');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
expect(alphaText, findsOneWidget);
|
||||
expect(find.textContaining('[ui] opened'), findsOneWidget);
|
||||
final networkTitle = find.byWidgetPredicate((w) =>
|
||||
@@ -75,6 +80,13 @@ void main() {
|
||||
expect(find.textContaining('[ui] opened'), findsNothing);
|
||||
expect(networkTitle, findsNothing);
|
||||
|
||||
// Search for something in network data to test auto-expansion
|
||||
await tester.enterText(searchField, 'apidash.dev');
|
||||
await tester.pumpAndSettle();
|
||||
expect(alphaText, findsNothing);
|
||||
expect(networkTitle, findsOneWidget);
|
||||
// Network tile should auto-expand when search matches URL
|
||||
|
||||
// Clear search
|
||||
await tester.enterText(searchField, '');
|
||||
await tester.pumpAndSettle();
|
||||
@@ -125,7 +137,7 @@ void main() {
|
||||
final netId = term.startNetwork(
|
||||
apiType: APIType.rest,
|
||||
method: HTTPVerb.get,
|
||||
url: 'https://example.com',
|
||||
url: 'https://api.apidash.dev',
|
||||
requestId: reqId,
|
||||
);
|
||||
term.completeNetwork(netId, statusCode: 200);
|
||||
@@ -142,4 +154,49 @@ void main() {
|
||||
(w) => w is RichText && w.text.toPlainText().startsWith('[Untitled] '));
|
||||
expect(netWithUntitled, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('search highlighting works across different tile types',
|
||||
(tester) async {
|
||||
final container = ProviderContainer();
|
||||
final term = container.read(terminalStateProvider.notifier);
|
||||
|
||||
// Add entries with common search term "test"
|
||||
term.logJs(
|
||||
level: 'info',
|
||||
args: ['test completed'],
|
||||
context: 'postRequest',
|
||||
contextRequestId: 'r1');
|
||||
term.logSystem(category: 'testing', message: 'Unit test passed');
|
||||
|
||||
final netId = term.startNetwork(
|
||||
apiType: APIType.rest,
|
||||
method: HTTPVerb.get,
|
||||
url: 'https://api.apidash.dev/api',
|
||||
requestId: 'r2',
|
||||
);
|
||||
term.completeNetwork(netId,
|
||||
statusCode: 200, responseBodyPreview: '{"test": "success"}');
|
||||
|
||||
await tester.pumpWidget(build(container));
|
||||
|
||||
// Search for "test"
|
||||
final searchField = find.byWidgetPredicate(
|
||||
(w) => w is TextField && w.decoration?.hintText == 'Search logs');
|
||||
await tester.enterText(searchField, 'test');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// All three entries should be visible since they all contain "test"
|
||||
expect(find.byWidgetPredicate((w) {
|
||||
if (w is SelectableText) {
|
||||
final text = w.data ?? w.textSpan?.toPlainText() ?? '';
|
||||
return text.contains('test');
|
||||
}
|
||||
return false;
|
||||
}), findsAtLeastNWidgets(1));
|
||||
expect(find.textContaining('[testing]'), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate((w) =>
|
||||
w is RichText && w.text.toPlainText().contains('api.apidash.dev')),
|
||||
findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
147
test/widgets/expandable_section_test.dart
Normal file
147
test/widgets/expandable_section_test.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:apidash/widgets/expandable_section.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('ExpandableSection starts collapsed by default', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ExpandableSection(
|
||||
title: 'Test Section',
|
||||
child: Text('Content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Test Section'), findsOneWidget);
|
||||
expect(find.text('Content'), findsNothing);
|
||||
expect(find.byIcon(Icons.keyboard_arrow_down), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ExpandableSection can be expanded by tapping', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ExpandableSection(
|
||||
title: 'Test Section',
|
||||
child: Text('Content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Tap to expand
|
||||
await tester.tap(find.byType(InkWell));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Test Section'), findsOneWidget);
|
||||
expect(find.text('Content'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ExpandableSection respects initiallyOpen parameter',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ExpandableSection(
|
||||
title: 'Test Section',
|
||||
initiallyOpen: true,
|
||||
child: Text('Content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Test Section'), findsOneWidget);
|
||||
expect(find.text('Content'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ExpandableSection respects forceOpen parameter', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ExpandableSection(
|
||||
title: 'Test Section',
|
||||
forceOpen: true,
|
||||
child: Text('Content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Test Section'), findsOneWidget);
|
||||
expect(find.text('Content'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget);
|
||||
|
||||
// Tapping should not close it when forceOpen is true
|
||||
await tester.tap(find.byType(InkWell));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Content'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ExpandableSection highlights title when highlightQuery matches',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ExpandableSection(
|
||||
title: 'Network Settings',
|
||||
highlightQuery: 'network',
|
||||
child: Text('Content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When highlighting is applied, the title is rendered as RichText instead of Text
|
||||
final richTexts = tester.widgetList<RichText>(find.byType(RichText));
|
||||
final titleRichText = richTexts.firstWhere(
|
||||
(rt) => rt.text.toPlainText() == 'Network Settings',
|
||||
orElse: () => throw Exception('RichText with title not found'),
|
||||
);
|
||||
expect(titleRichText.text.toPlainText(), 'Network Settings');
|
||||
});
|
||||
|
||||
testWidgets('ExpandableSection updates when forceOpen changes',
|
||||
(tester) async {
|
||||
bool forceOpen = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatefulBuilder(
|
||||
builder: (context, setState) => Column(
|
||||
children: [
|
||||
ExpandableSection(
|
||||
title: 'Test Section',
|
||||
forceOpen: forceOpen ? true : null,
|
||||
child: const Text('Content'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => setState(() => forceOpen = !forceOpen),
|
||||
child: const Text('Toggle Force Open'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Initially collapsed
|
||||
expect(find.text('Content'), findsNothing);
|
||||
|
||||
// Tap button to set forceOpen to true
|
||||
await tester.tap(find.text('Toggle Force Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should now be open
|
||||
expect(find.text('Content'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
212
test/widgets/highlight_text_test.dart
Normal file
212
test/widgets/highlight_text_test.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:apidash/widgets/highlight_text.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('HighlightedSelectableText renders plain text when no query',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: HighlightedSelectableText(
|
||||
text: 'Hello World',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Hello World'), findsOneWidget);
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
|
||||
final selectableText =
|
||||
tester.widget<SelectableText>(find.byType(SelectableText));
|
||||
expect(selectableText.data, 'Hello World');
|
||||
});
|
||||
|
||||
testWidgets('HighlightedSelectableText renders plain text when empty query',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: HighlightedSelectableText(
|
||||
text: 'Hello World',
|
||||
query: '',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Hello World'), findsOneWidget);
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
|
||||
final selectableText =
|
||||
tester.widget<SelectableText>(find.byType(SelectableText));
|
||||
expect(selectableText.data, 'Hello World');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'HighlightedSelectableText renders with highlights when query matches',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: HighlightedSelectableText(
|
||||
text: 'Hello World',
|
||||
query: 'World',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When highlighting is applied, it uses SelectableText.rich
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
|
||||
final selectableText =
|
||||
tester.widget<SelectableText>(find.byType(SelectableText));
|
||||
// The text should still contain the original content
|
||||
expect(selectableText.textSpan?.toPlainText(), 'Hello World');
|
||||
});
|
||||
|
||||
testWidgets('HighlightedSelectableText is case insensitive', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: HighlightedSelectableText(
|
||||
text: 'Hello World',
|
||||
query: 'HELLO',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
|
||||
final selectableText =
|
||||
tester.widget<SelectableText>(find.byType(SelectableText));
|
||||
expect(selectableText.textSpan?.toPlainText(), 'Hello World');
|
||||
});
|
||||
|
||||
testWidgets('HighlightedSelectableText handles multiple matches',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: HighlightedSelectableText(
|
||||
text: 'Hello Hello World',
|
||||
query: 'Hello',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
|
||||
final selectableText =
|
||||
tester.widget<SelectableText>(find.byType(SelectableText));
|
||||
expect(selectableText.textSpan?.toPlainText(), 'Hello Hello World');
|
||||
});
|
||||
|
||||
testWidgets('HighlightedSelectableText applies custom style', (tester) async {
|
||||
const customStyle = TextStyle(fontSize: 20, color: Colors.red);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: HighlightedSelectableText(
|
||||
text: 'Hello World',
|
||||
style: customStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
|
||||
final selectableText =
|
||||
tester.widget<SelectableText>(find.byType(SelectableText));
|
||||
expect(selectableText.style, customStyle);
|
||||
});
|
||||
|
||||
testWidgets('buildHighlightedSpans returns plain TextSpan when no query',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final spans = buildHighlightedSpans('Hello World', context, null);
|
||||
return RichText(
|
||||
text: TextSpan(children: spans),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(RichText), findsOneWidget);
|
||||
|
||||
final richText = tester.widget<RichText>(find.byType(RichText));
|
||||
expect(richText.text.toPlainText(), 'Hello World');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'buildHighlightedSpans returns highlighted spans when query matches',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final spans =
|
||||
buildHighlightedSpans('Hello World', context, 'World');
|
||||
return RichText(
|
||||
text: TextSpan(children: spans),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(RichText), findsOneWidget);
|
||||
|
||||
final richText = tester.widget<RichText>(find.byType(RichText));
|
||||
expect(richText.text.toPlainText(), 'Hello World');
|
||||
|
||||
// Should have multiple spans (before match, match, after match)
|
||||
final textSpan = richText.text as TextSpan;
|
||||
expect(textSpan.children, isNotNull);
|
||||
expect(textSpan.children!.length, greaterThan(1));
|
||||
});
|
||||
|
||||
testWidgets('buildHighlightedSpans applies custom base style',
|
||||
(tester) async {
|
||||
const baseStyle = TextStyle(fontSize: 18, color: Colors.blue);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final spans = buildHighlightedSpans(
|
||||
'Hello World',
|
||||
context,
|
||||
'World',
|
||||
baseStyle: baseStyle,
|
||||
);
|
||||
return RichText(
|
||||
text: TextSpan(children: spans, style: baseStyle),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(RichText), findsOneWidget);
|
||||
|
||||
final richText = tester.widget<RichText>(find.byType(RichText));
|
||||
expect(richText.text.style, baseStyle);
|
||||
});
|
||||
}
|
||||
@@ -18,12 +18,30 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: SystemLogTile(entry: entry))));
|
||||
expect(find.textContaining('[ui] Updated'), findsOneWidget);
|
||||
// Stack is rendered using SelectableText
|
||||
// Stack is rendered using HighlightedSelectableText which internally uses SelectableText
|
||||
final stackSelectable =
|
||||
tester.widget<SelectableText>(find.byType(SelectableText).first);
|
||||
tester.widget<SelectableText>(find.byType(SelectableText).last);
|
||||
expect(stackSelectable.data, 'trace');
|
||||
});
|
||||
|
||||
testWidgets('SystemLogTile renders with search highlighting', (tester) async {
|
||||
final entry = TerminalEntry(
|
||||
id: 's1',
|
||||
source: TerminalSource.system,
|
||||
level: TerminalLevel.info,
|
||||
system:
|
||||
SystemLogData(category: 'networking', message: 'Request completed'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SystemLogTile(entry: entry, searchQuery: 'network'))));
|
||||
|
||||
// Should find the highlighted text
|
||||
expect(
|
||||
find.textContaining('[networking] Request completed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('JsLogTile composes body text with context and requestName',
|
||||
(tester) async {
|
||||
final entry = TerminalEntry(
|
||||
@@ -41,6 +59,31 @@ void main() {
|
||||
// Background color for warn renders but we only assert presence of text
|
||||
});
|
||||
|
||||
testWidgets('JsLogTile highlights search query in body text', (tester) async {
|
||||
final entry = TerminalEntry(
|
||||
id: 'j1',
|
||||
source: TerminalSource.js,
|
||||
level: TerminalLevel.info,
|
||||
js: JsLogData(
|
||||
level: 'info',
|
||||
args: ['API', 'response', 'success'],
|
||||
context: 'postRequest'),
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: JsLogTile(
|
||||
entry: entry, requestName: 'TestAPI', searchQuery: 'API'))));
|
||||
|
||||
// Should find the text containing API in SelectableText widgets
|
||||
// When highlighting is applied, the content is in textSpan
|
||||
final selectables =
|
||||
tester.widgetList<SelectableText>(find.byType(SelectableText)).toList();
|
||||
final hasAPIText = selectables.any((s) =>
|
||||
(s.data != null && s.data!.contains('API')) ||
|
||||
(s.textSpan != null && s.textSpan!.toPlainText().contains('API')));
|
||||
expect(hasAPIText, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('NetworkLogTile expands to show details and status/duration',
|
||||
(tester) async {
|
||||
final n = NetworkLogData(
|
||||
@@ -90,4 +133,39 @@ void main() {
|
||||
expect(
|
||||
selectables.any((w) => (w.data ?? '').contains('Status: 200')), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('NetworkLogTile shows search query in URL', (tester) async {
|
||||
final n = NetworkLogData(
|
||||
phase: NetworkPhase.completed,
|
||||
apiType: APIType.rest,
|
||||
method: HTTPVerb.post,
|
||||
url: 'https://api.example.com/data',
|
||||
responseStatus: 201,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
requestHeaders: const {'Content-Type': 'application/json'},
|
||||
responseHeaders: const {'Server': 'nginx'},
|
||||
requestBodyPreview: '{"name": "test"}',
|
||||
responseBodyPreview: '{"id": 123}',
|
||||
);
|
||||
final entry = TerminalEntry(
|
||||
id: 'n1',
|
||||
source: TerminalSource.network,
|
||||
level: TerminalLevel.info,
|
||||
network: n);
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: NetworkLogTile(
|
||||
entry: entry,
|
||||
requestName: 'TestAPI',
|
||||
searchQuery: 'example'))));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The RichText should contain the search query "example" in the URL
|
||||
final richTexts = tester.widgetList<RichText>(find.byType(RichText));
|
||||
final hasExampleText =
|
||||
richTexts.any((rt) => rt.text.toPlainText().contains('example'));
|
||||
expect(hasExampleText, isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user