diff --git a/docs/dev/MAINTAINERS.md b/docs/dev/MAINTAINERS.md index a3550bd21..f55076d35 100644 --- a/docs/dev/MAINTAINERS.md +++ b/docs/dev/MAINTAINERS.md @@ -94,6 +94,7 @@ The version in our package.json gets copied to the one we publish, and users nee - **graphql**: Changes related to the graphql client - **graphql_flutter**: Changes related to the graphql_flutter package +- **graphql_devtools_extension**: Changes related to the devtools extension ### Subject @@ -155,4 +156,4 @@ if we make a release with the tag `v{version_number}` this will release all the Cheers! -[Vincent](https://github.com/vincenzopalazzo) \ No newline at end of file +[Vincent](https://github.com/vincenzopalazzo) diff --git a/packages/graphql/extension/devtools/.pubignore b/packages/graphql/extension/devtools/.pubignore new file mode 100644 index 000000000..71860a75d --- /dev/null +++ b/packages/graphql/extension/devtools/.pubignore @@ -0,0 +1 @@ +!build diff --git a/packages/graphql/extension/devtools/config.yaml b/packages/graphql/extension/devtools/config.yaml new file mode 100644 index 000000000..5923fbc13 --- /dev/null +++ b/packages/graphql/extension/devtools/config.yaml @@ -0,0 +1,6 @@ +name: graphql +issueTracker: https://github.com/zino-hofmann/graphql-flutter/pulls +version: 0.0.1 +materialIconCodePoint: '0xe4f9' +requiresConnection: true + diff --git a/packages/graphql/lib/src/graphql_client.dart b/packages/graphql/lib/src/graphql_client.dart index b7bea77a6..08aef1e54 100644 --- a/packages/graphql/lib/src/graphql_client.dart +++ b/packages/graphql/lib/src/graphql_client.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:developer'; + import 'package:meta/meta.dart'; import 'dart:async'; @@ -6,6 +9,9 @@ import 'package:graphql/src/cache/cache.dart'; import 'package:graphql/src/core/fetch_more.dart'; +/// Flag to register extension only once +bool _isExtensionRegistered = false; + /// Universal GraphQL Client with configurable caching and [link][] system. /// modelled after the [`apollo-client`][ac]. /// @@ -37,7 +43,21 @@ class GraphQLClient implements GraphQLDataProxy { deepEquals: deepEquals, deduplicatePollers: deduplicatePollers, requestTimeout: queryRequestTimeout, - ); + ) { + const releaseMode = bool.fromEnvironment('dart.vm.product'); + // Register extension for not in release mode and not already registered + if (!releaseMode && !_isExtensionRegistered) { + // Register the extension to expose the cache to the devtools + registerExtension( + 'ext.graphql.getCache', + (method, parameters) async { + return ServiceExtensionResponse.result( + jsonEncode({'value': this.cache.store.toMap()})); + }, + ); + _isExtensionRegistered = true; + } + } /// The default [Policies] to set for each client action late final DefaultPolicies defaultPolicies; diff --git a/packages/graphql_devtools_extension/CAHNGELOG.md b/packages/graphql_devtools_extension/CAHNGELOG.md new file mode 100644 index 000000000..f44677dbb --- /dev/null +++ b/packages/graphql_devtools_extension/CAHNGELOG.md @@ -0,0 +1,2 @@ +# v0.0.1 +Initial release diff --git a/packages/graphql_devtools_extension/README.md b/packages/graphql_devtools_extension/README.md new file mode 100644 index 000000000..4669b6080 --- /dev/null +++ b/packages/graphql_devtools_extension/README.md @@ -0,0 +1,7 @@ +This is the source of the graphql's devtool. + +You can locally run it with: + +``` +flutter run -d chrome --dart-define=use_simulated_environment=true +``` diff --git a/packages/graphql_devtools_extension/analysis_options.yaml b/packages/graphql_devtools_extension/analysis_options.yaml new file mode 100644 index 000000000..b5523d95a --- /dev/null +++ b/packages/graphql_devtools_extension/analysis_options.yaml @@ -0,0 +1,5 @@ +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/packages/graphql_devtools_extension/lib/api/app_connection.dart b/packages/graphql_devtools_extension/lib/api/app_connection.dart new file mode 100644 index 000000000..ac26fa108 --- /dev/null +++ b/packages/graphql_devtools_extension/lib/api/app_connection.dart @@ -0,0 +1,12 @@ +import 'package:devtools_extensions/devtools_extensions.dart'; + +class AppConnection { + static const _cacheApiKey = 'ext.graphql.getCache'; + static const _cacheApiValueKey = 'value'; + + static Future?> fetchCache() async { + final result = + await serviceManager.callServiceExtensionOnMainIsolate(_cacheApiKey); + return result.json?[_cacheApiValueKey] as Map?; + } +} diff --git a/packages/graphql_devtools_extension/lib/main.dart b/packages/graphql_devtools_extension/lib/main.dart new file mode 100644 index 000000000..2b7db0cd6 --- /dev/null +++ b/packages/graphql_devtools_extension/lib/main.dart @@ -0,0 +1,39 @@ +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:graphql_devtools_extension/ui/cache_inspector/cache_inspector.dart'; + +void main() { + runApp(DevToolsExtension(child: GraphQLDevToolsExtension())); +} + +class GraphQLDevToolsExtension extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DefaultTabController( + length: 1, + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: TabBar( + isScrollable: true, + tabs: [ + Tab( + child: Text( + 'Cache Inspector', + style: theme.textTheme.titleMedium, + ), + ) + ], + ), + ), + const SizedBox(height: 24), + Expanded( + child: TabBarView(children: [const CacheInspector()]), + ), + ], + ), + ); + } +} diff --git a/packages/graphql_devtools_extension/lib/ui/cache_inspector/cache_inspector.dart b/packages/graphql_devtools_extension/lib/ui/cache_inspector/cache_inspector.dart new file mode 100644 index 000000000..f038e4b69 --- /dev/null +++ b/packages/graphql_devtools_extension/lib/ui/cache_inspector/cache_inspector.dart @@ -0,0 +1,314 @@ +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; +import 'package:graphql_devtools_extension/api/app_connection.dart'; +import 'package:graphql_devtools_extension/ui/cache_inspector/tree_controller.dart'; +import 'package:graphql_devtools_extension/ui/cache_inspector/tree_view.dart'; + +class SelectedCache { + const SelectedCache({required this.key, required this.value}); + final String key; + final dynamic value; +} + +class CacheInspector extends StatefulWidget { + const CacheInspector({Key? key}) : super(key: key); + @override + _CacheInspectorState createState() => _CacheInspectorState(); +} + +class _CacheInspectorState extends State { + static const _queryKey = 'Query'; + static const _mutationKey = 'Mutation'; + + Map? sourceData; + Map? cache; + Map? query; + Map? mutation; + String q = ''; + SelectedCache? selectedCache; + bool showSearchTab = false; + late TreeController treeController; + late Future?> response; + + @override + void initState() { + super.initState(); + treeController = TreeController(); + response = AppConnection.fetchCache(); + response.then((cacheMap) { + if (cacheMap != null) { + setState(() { + sourceData = Map.from(cacheMap); + query = _sortCache(cacheMap[_queryKey] as Map?); + mutation = + _sortCache(cacheMap[_mutationKey] as Map?); + cache = _sortCache(cacheMap + ..remove(_queryKey) + ..remove(_mutationKey)); + }); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DefaultTabController( + length: 3, + child: Split( + initialFractions: const [0.5, 0.5], + axis: Axis.horizontal, + children: [ + RoundedOutlinedBorder( + clip: true, + child: Column( + children: [ + Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: TabBar( + isScrollable: true, + tabs: [ + Tab( + child: Text( + 'Cache', + style: theme.textTheme.titleMedium, + ), + ), + Tab( + child: Text( + _queryKey, + style: theme.textTheme.titleMedium, + ), + ), + Tab( + child: Text( + _mutationKey, + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + ), + Positioned( + top: 4, + right: 48, + child: InkWell( + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.search), + ), + onTap: () { + setState(() { + showSearchTab = !showSearchTab; + }); + }, + ), + ), + Positioned( + top: 4, + right: 0, + child: InkWell( + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.refresh), + ), + onTap: () async { + q = ''; + selectedCache = null; + showSearchTab = false; + final cacheMap = await AppConnection.fetchCache(); + if (cacheMap is Map) { + setState(() { + sourceData = cacheMap; + query = + cacheMap[_queryKey] is Map + ? _sortCache(cacheMap[_queryKey] + as Map) + : {}; + mutation = + cacheMap[_mutationKey] is Map + ? _sortCache(cacheMap[_mutationKey] + as Map) + : {}; + final cacheMapCopy = + Map.from(cacheMap); + cache = _sortCache(cacheMapCopy + ..remove(_queryKey) + ..remove(_mutationKey)); + }); + } + }, + ), + ), + ], + ), + const Divider(height: 1), + if (showSearchTab) ...[ + const SizedBox(height: 8), + TextField( + autofocus: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Search Cache', + contentPadding: + EdgeInsets.symmetric(horizontal: 16, vertical: 0), + ), + onChanged: (value) { + if (sourceData == null) return; + final cacheCopy = Map.from(sourceData!); + final tmpQuery = cacheCopy[_queryKey]; + final tmpMutation = cacheCopy[_mutationKey]; + setState(() { + query = _sortCache(_filterCache( + tmpQuery as Map?, value)); + mutation = _sortCache(_filterCache( + tmpMutation as Map?, value)); + cache = _sortCache(_filterCache( + cacheCopy + ..remove(_queryKey) + ..remove(_mutationKey), + value, + )); + q = value; + }); + }, + ), + ], + Expanded( + child: TabBarView( + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildTreeView( + context, + data: cache, + onTap: (key, value) { + if (key != null) { + setState(() { + selectedCache = + SelectedCache(key: key, value: value); + }); + } + }, + ), + _buildTreeView( + context, + data: query, + onTap: (key, value) { + if (key != null) { + setState(() { + selectedCache = + SelectedCache(key: key, value: value); + }); + } + }, + ), + _buildTreeView( + context, + data: mutation, + onTap: (key, value) { + if (key != null) { + setState(() { + selectedCache = + SelectedCache(key: key, value: value); + }); + } + }, + ), + ], + ), + ), + ], + ), + ), + RoundedOutlinedBorder( + clip: true, + child: SafeArea( + child: SingleChildScrollView( + child: TreeView( + treeController: treeController, + nodes: selectedCache == null + ? [const TreeNode(children: [])] + : _convertToTreeNodes({ + selectedCache!.key: + _sortCacheValue(selectedCache!.value) + }), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTreeView( + BuildContext context, { + required Map? data, + required void Function(String? key, dynamic value) onTap, + }) { + return ListView.builder( + itemCount: data?.length ?? 0, + itemBuilder: (context, index) { + final key = data?.keys.elementAt(index); + return ListTile( + title: Text('$key: ', style: Theme.of(context).textTheme.titleMedium), + onTap: () => onTap(key, data?[key]), + ); + }, + ); + } + + List _convertToTreeNodes(dynamic parsedJson) { + if (parsedJson is Map) { + return parsedJson.keys + .map((k) => TreeNode( + content: '$k:', children: _convertToTreeNodes(parsedJson[k]))) + .toList(); + } + if (parsedJson is List) { + if (parsedJson.isEmpty) return [const TreeNode(content: '[]')]; + return parsedJson + .asMap() + .map((i, element) => MapEntry( + i, + TreeNode( + content: '[$i]:', children: _convertToTreeNodes(element)))) + .values + .toList(); + } + return [TreeNode(content: parsedJson.toString())]; + } + + Map _sortCache(Map? cacheMap) { + if (cacheMap == null) return {}; + final sortedKeys = cacheMap.keys.toList()..sort((a, b) => a.compareTo(b)); + return {for (var key in sortedKeys) key: cacheMap[key]}; + } + + dynamic _sortCacheValue(dynamic cacheValue) { + if (cacheValue is! Map) return cacheValue; + final sortedKeys = cacheValue.keys.toList() + ..sort((a, b) { + if (a == 'id') return -1; + if (b == 'id') return 1; + if (a == '__typename') return -1; + if (b == '__typename') return 1; + final isAMap = cacheValue[a] is Map || cacheValue[a] is List; + final isBMap = cacheValue[b] is Map || cacheValue[b] is List; + if (isAMap && !isBMap) return 1; + if (!isAMap && isBMap) return -1; + return a.compareTo(b); + }); + return {for (var key in sortedKeys) key: cacheValue[key]}; + } + + Map _filterCache(Map? cacheMap, String q) { + if (cacheMap == null) return {}; + if (q.isEmpty) return cacheMap; + return cacheMap.map( + (k, v) => k.contains(q) ? MapEntry(k, v) : const MapEntry('', null)) + ..removeWhere((key, value) => key.isEmpty); + } +} diff --git a/packages/graphql_devtools_extension/lib/ui/cache_inspector/tree_controller.dart b/packages/graphql_devtools_extension/lib/ui/cache_inspector/tree_controller.dart new file mode 100644 index 000000000..6851dc16a --- /dev/null +++ b/packages/graphql_devtools_extension/lib/ui/cache_inspector/tree_controller.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// A controller for a tree state. +/// +/// Allows to modify the state of the tree. +class TreeController { + TreeController({bool allNodesExpanded = true}) + : _allNodesExpanded = allNodesExpanded; + bool _allNodesExpanded; + final Map _expanded = {}; + + bool get allNodesExpanded => _allNodesExpanded; + + bool isNodeExpanded(Key key) { + return _expanded[key] ?? _allNodesExpanded; + } + + void toggleNodeExpanded(Key key) { + _expanded[key] = !isNodeExpanded(key); + } + + void expandAll() { + _allNodesExpanded = true; + _expanded.clear(); + } + + void collapseAll() { + _allNodesExpanded = false; + _expanded.clear(); + } + + void expandNode(Key key) { + _expanded[key] = true; + } + + void collapseNode(Key key) { + _expanded[key] = false; + } +} diff --git a/packages/graphql_devtools_extension/lib/ui/cache_inspector/tree_view.dart b/packages/graphql_devtools_extension/lib/ui/cache_inspector/tree_view.dart new file mode 100644 index 000000000..174f4a8d7 --- /dev/null +++ b/packages/graphql_devtools_extension/lib/ui/cache_inspector/tree_view.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:graphql_devtools_extension/ui/cache_inspector/tree_controller.dart'; + +/// Tree view with collapsible and expandable nodes. +class TreeView extends StatelessWidget { + TreeView({ + required List nodes, + required this.treeController, + this.indent = 32, + this.iconSize, + }) : nodes = _copyNodesRecursively(nodes, _KeyProvider())!; + + /// List of root level tree nodes. + final List nodes; + + /// Horizontal indent between levels. + final double? indent; + + /// Size of the expand/collapse icon. + final double? iconSize; + + /// Tree controller to manage the tree state. + final TreeController treeController; + + @override + Widget build(BuildContext context) { + return _buildNodes(nodes, indent, treeController, iconSize); + } +} + +/// Widget that displays one [TreeNode] and its children. +class NodeWidget extends StatefulWidget { + const NodeWidget({ + required this.treeNode, + required this.treeController, + this.indent, + this.iconSize, + }); + final TreeNode treeNode; + final double? indent; + final double? iconSize; + final TreeController treeController; + @override + _NodeWidgetState createState() => _NodeWidgetState(); +} + +class _NodeWidgetState extends State { + late bool isExpanded; + @override + void initState() { + super.initState(); + isExpanded = widget.treeController.isNodeExpanded(widget.treeNode.key!); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isLeaf = widget.treeNode.children?.length == 1 && + widget.treeNode.children!.first.children == null; + if (isLeaf) { + return ListTile( + title: RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${widget.treeNode.content} ', + style: theme.textTheme.titleMedium, + ), + TextSpan( + text: widget.treeNode.children!.first.content, + style: theme.textTheme.titleMedium + ?.copyWith(color: theme.colorScheme.primary), + ), + ], + ), + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + setState(() { + widget.treeController.toggleNodeExpanded(widget.treeNode.key!); + isExpanded = + widget.treeController.isNodeExpanded(widget.treeNode.key!); + }); + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Icon( + isExpanded ? Icons.expand_more : Icons.chevron_right, + size: widget.iconSize, + ), + ), + Expanded( + child: Text( + widget.treeNode.content, + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + ), + ), + if (isExpanded) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only(left: widget.indent! + 8.0), + child: _buildNodes( + widget.treeNode.children!, + widget.indent, + widget.treeController, + widget.iconSize, + ), + ), + ), + ], + ), + ], + ); + } +} + +class _TreeNodeKey extends ValueKey { + const _TreeNodeKey(int value) : super(value); +} + +/// Provides unique keys and verifies duplicates. +class _KeyProvider { + int _nextIndex = 0; + final Set _keys = {}; + + /// If [originalKey] is null, generates new key, otherwise verifies the key + /// was not met before. + Key key(Key? originalKey) { + if (originalKey == null) { + return _TreeNodeKey(_nextIndex++); + } + if (_keys.contains(originalKey)) { + throw ArgumentError('There should not be nodes with the same kays. ' + 'Duplicate value found: $originalKey.'); + } + _keys.add(originalKey); + return originalKey; + } +} + +/// One node of a tree. +class TreeNode { + const TreeNode({this.children, this.content = '', this.key}); + + final List? children; + final String content; + final Key? key; +} + +/// Builds set of [nodes] respecting [state], [indent] and [iconSize]. +Widget _buildNodes( + Iterable nodes, + double? indent, + TreeController state, + double? iconSize, +) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final node in nodes) + NodeWidget( + treeNode: node, + indent: indent, + treeController: state, + iconSize: iconSize, + ), + ], + ); +} + +List? _copyNodesRecursively( + List? nodes, + _KeyProvider keyProvider, +) { + if (nodes == null) { + return null; + } + return List.unmodifiable( + nodes.map( + (n) { + return TreeNode( + key: keyProvider.key(n.key), + content: n.content, + children: _copyNodesRecursively(n.children, keyProvider), + ); + }, + ), + ); +} diff --git a/packages/graphql_devtools_extension/pubspec.yaml b/packages/graphql_devtools_extension/pubspec.yaml new file mode 100644 index 000000000..b0eba48c1 --- /dev/null +++ b/packages/graphql_devtools_extension/pubspec.yaml @@ -0,0 +1,18 @@ +name: graphql_devtools_extension +version: 0.0.1 +repository: https://github.com/zino-app/graphql-flutter/tree/main/packages/graphql_devtools_extension +issue_tracker: https://github.com/zino-hofmann/graphql-flutter/issues + +environment: + sdk: '>=2.12.0 <4.0.0' + flutter: ">=2.11.0" + +dependencies: + flutter: + sdk: flutter + + devtools_extensions: 0.0.14 + devtools_app_shared: 0.0.10 + +flutter: + uses-material-design: true diff --git a/packages/graphql_devtools_extension/web/favicon.png b/packages/graphql_devtools_extension/web/favicon.png new file mode 100644 index 000000000..8aaa46ac1 Binary files /dev/null and b/packages/graphql_devtools_extension/web/favicon.png differ diff --git a/packages/graphql_devtools_extension/web/icons/Icon-192.png b/packages/graphql_devtools_extension/web/icons/Icon-192.png new file mode 100644 index 000000000..b749bfef0 Binary files /dev/null and b/packages/graphql_devtools_extension/web/icons/Icon-192.png differ diff --git a/packages/graphql_devtools_extension/web/icons/Icon-512.png b/packages/graphql_devtools_extension/web/icons/Icon-512.png new file mode 100644 index 000000000..88cfd48df Binary files /dev/null and b/packages/graphql_devtools_extension/web/icons/Icon-512.png differ diff --git a/packages/graphql_devtools_extension/web/icons/Icon-maskable-192.png b/packages/graphql_devtools_extension/web/icons/Icon-maskable-192.png new file mode 100644 index 000000000..eb9b4d76e Binary files /dev/null and b/packages/graphql_devtools_extension/web/icons/Icon-maskable-192.png differ diff --git a/packages/graphql_devtools_extension/web/icons/Icon-maskable-512.png b/packages/graphql_devtools_extension/web/icons/Icon-maskable-512.png new file mode 100644 index 000000000..d69c56691 Binary files /dev/null and b/packages/graphql_devtools_extension/web/icons/Icon-maskable-512.png differ diff --git a/packages/graphql_devtools_extension/web/index.html b/packages/graphql_devtools_extension/web/index.html new file mode 100644 index 000000000..a5f551757 --- /dev/null +++ b/packages/graphql_devtools_extension/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + graphql_devtools_extension + + + + + + + + + + diff --git a/packages/graphql_devtools_extension/web/manifest.json b/packages/graphql_devtools_extension/web/manifest.json new file mode 100644 index 000000000..7fd0c2386 --- /dev/null +++ b/packages/graphql_devtools_extension/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "graphql_devtools_extension", + "short_name": "graphql_devtools_extension", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}