Skip to main content

oxirs_gql/
query_builder.rs

1//! Visual Query Builder UI
2//!
3//! Provides an interactive, no-code query building interface for GraphQL.
4//! Users can visually construct queries by selecting fields, adding filters,
5//! and configuring options without writing GraphQL syntax.
6//!
7//! ## Features
8//!
9//! - **Visual Field Selection**: Point-and-click field picker from schema
10//! - **Filter Builder**: Visual filter/where clause constructor
11//! - **Pagination UI**: Configure limit, offset, cursors visually
12//! - **Sorting UI**: Drag-and-drop sort field ordering
13//! - **Variable Manager**: Visual variable configuration
14//! - **Query Preview**: Live GraphQL query preview
15//! - **Export Options**: Export as query, curl, code snippets
16
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20/// Query Builder configuration
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct QueryBuilderConfig {
23    /// Enable query builder
24    pub enabled: bool,
25    /// Endpoint path (default: /query-builder)
26    pub endpoint: String,
27    /// Page title
28    pub title: String,
29    /// Show live preview
30    pub show_preview: bool,
31    /// Enable query execution
32    pub enable_execution: bool,
33    /// Enable export features
34    pub enable_export: bool,
35    /// Maximum query depth
36    pub max_depth: usize,
37    /// Custom CSS URL
38    pub custom_css: Option<String>,
39}
40
41impl Default for QueryBuilderConfig {
42    fn default() -> Self {
43        Self {
44            enabled: true,
45            endpoint: "/query-builder".to_string(),
46            title: "OxiRS GraphQL Query Builder".to_string(),
47            show_preview: true,
48            enable_execution: true,
49            enable_export: true,
50            max_depth: 5,
51            custom_css: None,
52        }
53    }
54}
55
56/// Field selection in query builder
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FieldSelection {
59    /// Field name
60    pub name: String,
61    /// Field type
62    pub field_type: String,
63    /// Is selected
64    pub selected: bool,
65    /// Arguments
66    pub arguments: HashMap<String, String>,
67    /// Nested fields (for objects)
68    pub nested_fields: Vec<FieldSelection>,
69}
70
71/// Filter operation
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum FilterOp {
74    Equals,
75    NotEquals,
76    GreaterThan,
77    LessThan,
78    GreaterThanOrEqual,
79    LessThanOrEqual,
80    Contains,
81    StartsWith,
82    EndsWith,
83    In,
84    NotIn,
85}
86
87impl std::fmt::Display for FilterOp {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            FilterOp::Equals => write!(f, "="),
91            FilterOp::NotEquals => write!(f, "!="),
92            FilterOp::GreaterThan => write!(f, ">"),
93            FilterOp::LessThan => write!(f, "<"),
94            FilterOp::GreaterThanOrEqual => write!(f, ">="),
95            FilterOp::LessThanOrEqual => write!(f, "<="),
96            FilterOp::Contains => write!(f, "contains"),
97            FilterOp::StartsWith => write!(f, "startsWith"),
98            FilterOp::EndsWith => write!(f, "endsWith"),
99            FilterOp::In => write!(f, "in"),
100            FilterOp::NotIn => write!(f, "notIn"),
101        }
102    }
103}
104
105/// Filter condition
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct FilterCondition {
108    /// Field to filter
109    pub field: String,
110    /// Filter operation
111    pub operation: FilterOp,
112    /// Filter value
113    pub value: String,
114}
115
116/// Sort direction
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118pub enum SortDirection {
119    Ascending,
120    Descending,
121}
122
123impl std::fmt::Display for SortDirection {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            SortDirection::Ascending => write!(f, "ASC"),
127            SortDirection::Descending => write!(f, "DESC"),
128        }
129    }
130}
131
132/// Sort field
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct SortField {
135    /// Field name
136    pub field: String,
137    /// Sort direction
138    pub direction: SortDirection,
139}
140
141/// Query builder state
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct QueryBuilderState {
144    /// Selected operation type (query/mutation/subscription)
145    pub operation_type: String,
146    /// Selected root field
147    pub root_field: String,
148    /// Selected fields
149    pub fields: Vec<FieldSelection>,
150    /// Filter conditions
151    pub filters: Vec<FilterCondition>,
152    /// Sort fields
153    pub sorting: Vec<SortField>,
154    /// Pagination: limit
155    pub limit: Option<usize>,
156    /// Pagination: offset
157    pub offset: Option<usize>,
158    /// Variables
159    pub variables: HashMap<String, String>,
160}
161
162impl Default for QueryBuilderState {
163    fn default() -> Self {
164        Self {
165            operation_type: "query".to_string(),
166            root_field: String::new(),
167            fields: Vec::new(),
168            filters: Vec::new(),
169            sorting: Vec::new(),
170            limit: None,
171            offset: None,
172            variables: HashMap::new(),
173        }
174    }
175}
176
177/// Generate HTML for Query Builder
178pub fn generate_query_builder_html(config: &QueryBuilderConfig, graphql_endpoint: &str) -> String {
179    let custom_css = config
180        .custom_css
181        .as_ref()
182        .map(|url| format!(r#"<link rel="stylesheet" href="{}">"#, url))
183        .unwrap_or_default();
184
185    format!(
186        r#"<!DOCTYPE html>
187<html lang="en">
188<head>
189    <meta charset="UTF-8">
190    <meta name="viewport" content="width=device-width, initial-scale=1.0">
191    <title>{title}</title>
192    <style>
193        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
194        body {{
195            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
196            background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
197            min-height: 100vh;
198            padding: 20px;
199        }}
200        .container {{
201            max-width: 1600px;
202            margin: 0 auto;
203            background: white;
204            border-radius: 12px;
205            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
206            overflow: hidden;
207            display: grid;
208            grid-template-columns: 400px 1fr 500px;
209            min-height: calc(100vh - 40px);
210        }}
211        .panel {{
212            overflow-y: auto;
213        }}
214        .panel-header {{
215            padding: 20px;
216            background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
217            color: white;
218            font-size: 18px;
219            font-weight: 600;
220        }}
221        .panel-content {{
222            padding: 20px;
223        }}
224        .section {{
225            margin-bottom: 24px;
226        }}
227        .section-title {{
228            font-size: 13px;
229            font-weight: 600;
230            color: #4a5568;
231            margin-bottom: 12px;
232            text-transform: uppercase;
233            letter-spacing: 0.5px;
234        }}
235        .field-tree {{
236            border: 1px solid #e2e8f0;
237            border-radius: 6px;
238            overflow: hidden;
239        }}
240        .field-item {{
241            padding: 10px 12px;
242            border-bottom: 1px solid #f7fafc;
243            display: flex;
244            align-items: center;
245            gap: 8px;
246            cursor: pointer;
247            transition: background 0.2s;
248        }}
249        .field-item:hover {{
250            background: #f7fafc;
251        }}
252        .field-item input[type="checkbox"] {{
253            cursor: pointer;
254        }}
255        .field-name {{
256            flex: 1;
257            font-size: 14px;
258            color: #2d3748;
259        }}
260        .field-type {{
261            font-size: 12px;
262            color: #718096;
263            font-family: monospace;
264        }}
265        .filter-list, .sort-list {{
266            display: flex;
267            flex-direction: column;
268            gap: 8px;
269        }}
270        .filter-item, .sort-item {{
271            display: grid;
272            grid-template-columns: 1fr auto 1fr auto;
273            gap: 8px;
274            align-items: center;
275            padding: 10px;
276            background: #f7fafc;
277            border-radius: 6px;
278        }}
279        select, input[type="text"], input[type="number"] {{
280            padding: 8px 12px;
281            border: 1px solid #cbd5e0;
282            border-radius: 4px;
283            font-size: 14px;
284        }}
285        select:focus, input:focus {{
286            outline: none;
287            border-color: #f093fb;
288            box-shadow: 0 0 0 3px rgba(240, 147, 251, 0.1);
289        }}
290        .btn {{
291            padding: 8px 16px;
292            border: none;
293            border-radius: 6px;
294            font-size: 14px;
295            font-weight: 500;
296            cursor: pointer;
297            transition: all 0.2s;
298        }}
299        .btn-primary {{
300            background: #f093fb;
301            color: white;
302        }}
303        .btn-primary:hover {{
304            background: #e081ea;
305            transform: translateY(-1px);
306            box-shadow: 0 4px 12px rgba(240, 147, 251, 0.4);
307        }}
308        .btn-secondary {{
309            background: #e2e8f0;
310            color: #2d3748;
311        }}
312        .btn-secondary:hover {{
313            background: #cbd5e0;
314        }}
315        .btn-small {{
316            padding: 4px 12px;
317            font-size: 12px;
318        }}
319        .btn-danger {{
320            background: #fc8181;
321            color: white;
322        }}
323        .code-preview {{
324            background: #1a202c;
325            color: #e2e8f0;
326            padding: 16px;
327            border-radius: 6px;
328            font-family: 'Monaco', 'Menlo', monospace;
329            font-size: 13px;
330            line-height: 1.6;
331            overflow-x: auto;
332            margin-bottom: 16px;
333        }}
334        .toolbar {{
335            display: flex;
336            gap: 8px;
337            flex-wrap: wrap;
338        }}
339        .response-area {{
340            background: #f7fafc;
341            border: 1px solid #e2e8f0;
342            border-radius: 6px;
343            padding: 16px;
344            min-height: 200px;
345            overflow-x: auto;
346        }}
347        .loading {{
348            text-align: center;
349            padding: 40px;
350            color: #718096;
351        }}
352        .error {{
353            background: #fed7d7;
354            color: #c53030;
355            padding: 16px;
356            border-radius: 6px;
357        }}
358        .middle-panel {{
359            border-left: 1px solid #e2e8f0;
360            border-right: 1px solid #e2e8f0;
361        }}
362        .pagination-grid {{
363            display: grid;
364            grid-template-columns: 1fr 1fr;
365            gap: 12px;
366        }}
367        .form-group {{
368            display: flex;
369            flex-direction: column;
370            gap: 6px;
371        }}
372        .form-label {{
373            font-size: 12px;
374            font-weight: 500;
375            color: #4a5568;
376        }}
377    </style>
378    {custom_css}
379</head>
380<body>
381    <div class="container">
382        <!-- Left Panel: Field Selection -->
383        <div class="panel">
384            <div class="panel-header">๐ŸŽฏ Field Selection</div>
385            <div class="panel-content">
386                <div class="section">
387                    <div class="section-title">Available Fields</div>
388                    <div class="field-tree" id="fieldTree">
389                        <div class="loading">Loading schema...</div>
390                    </div>
391                </div>
392            </div>
393        </div>
394
395        <!-- Middle Panel: Filters & Options -->
396        <div class="panel middle-panel">
397            <div class="panel-header">โš™๏ธ Query Configuration</div>
398            <div class="panel-content">
399                <div class="section">
400                    <div class="section-title">Filters</div>
401                    <div class="filter-list" id="filterList"></div>
402                    <button class="btn btn-secondary btn-small" onclick="addFilter()">+ Add Filter</button>
403                </div>
404
405                <div class="section">
406                    <div class="section-title">Sorting</div>
407                    <div class="sort-list" id="sortList"></div>
408                    <button class="btn btn-secondary btn-small" onclick="addSort()">+ Add Sort</button>
409                </div>
410
411                <div class="section">
412                    <div class="section-title">Pagination</div>
413                    <div class="pagination-grid">
414                        <div class="form-group">
415                            <label class="form-label" for="limitInput">Limit</label>
416                            <input type="number" id="limitInput" placeholder="10" min="1">
417                        </div>
418                        <div class="form-group">
419                            <label class="form-label" for="offsetInput">Offset</label>
420                            <input type="number" id="offsetInput" placeholder="0" min="0">
421                        </div>
422                    </div>
423                </div>
424            </div>
425        </div>
426
427        <!-- Right Panel: Preview & Execute -->
428        <div class="panel">
429            <div class="panel-header">๐Ÿ“ Query Preview</div>
430            <div class="panel-content">
431                <div class="section">
432                    <div class="section-title">Generated Query</div>
433                    <div class="code-preview" id="queryPreview">
434                        Select fields to build your query
435                    </div>
436                    <div class="toolbar">
437                        <button class="btn btn-primary" onclick="executeQuery()">โ–ถ Run Query</button>
438                        <button class="btn btn-secondary" onclick="copyQuery()">๐Ÿ“‹ Copy</button>
439                        <button class="btn btn-secondary" onclick="exportQuery()">๐Ÿ’พ Export</button>
440                        <button class="btn btn-secondary" onclick="resetBuilder()">๐Ÿ”„ Reset</button>
441                    </div>
442                </div>
443
444                <div class="section">
445                    <div class="section-title">Response</div>
446                    <div class="response-area" id="responseArea">
447                        <div class="loading">Build and execute a query to see results</div>
448                    </div>
449                </div>
450            </div>
451        </div>
452    </div>
453
454    <script>
455        const graphqlEndpoint = '{graphql_endpoint}';
456        let selectedFields = new Set();
457        let filters = [];
458        let sorting = [];
459
460        // Mock schema (in production, fetch from introspection)
461        const mockSchema = {{
462            fields: [
463                {{ name: 'hello', type: 'String!', description: 'Greeting message' }},
464                {{ name: 'version', type: 'String!', description: 'API version' }},
465                {{ name: 'triples', type: 'Int!', description: 'Triple count' }},
466                {{ name: 'subjects', type: '[String!]!', description: 'Subject IRIs', hasArgs: true }},
467                {{ name: 'predicates', type: '[String!]!', description: 'Predicate IRIs', hasArgs: true }},
468                {{ name: 'objects', type: '[String!]!', description: 'Object values', hasArgs: true }},
469                {{ name: 'sparql', type: 'String', description: 'Execute SPARQL', hasArgs: true }},
470            ]
471        }};
472
473        function renderFieldTree() {{
474            const html = mockSchema.fields.map(field => `
475                <div class="field-item">
476                    <input type="checkbox" id="field-${{field.name}}" onchange="toggleField('${{field.name}}')">
477                    <span class="field-name">${{field.name}}</span>
478                    <span class="field-type">${{field.type}}</span>
479                </div>
480            `).join('');
481            document.getElementById('fieldTree').innerHTML = html;
482        }}
483
484        function toggleField(fieldName) {{
485            if (selectedFields.has(fieldName)) {{
486                selectedFields.delete(fieldName);
487            }} else {{
488                selectedFields.add(fieldName);
489            }}
490            updateQueryPreview();
491        }}
492
493        function addFilter() {{
494            filters.push({{ field: '', op: 'Equals', value: '' }});
495            renderFilters();
496            updateQueryPreview();
497        }}
498
499        function removeFilter(index) {{
500            filters.splice(index, 1);
501            renderFilters();
502            updateQueryPreview();
503        }}
504
505        function renderFilters() {{
506            const html = filters.map((filter, i) => `
507                <div class="filter-item">
508                    <select onchange="updateFilter(${{i}}, 'field', this.value)">
509                        <option value="">Select field...</option>
510                        ${{Array.from(selectedFields).map(f =>
511                            `<option value="${{f}}" ${{filter.field === f ? 'selected' : ''}}>${{f}}</option>`
512                        ).join('')}}
513                    </select>
514                    <select onchange="updateFilter(${{i}}, 'op', this.value)">
515                        <option value="Equals" ${{filter.op === 'Equals' ? 'selected' : ''}}>equals</option>
516                        <option value="Contains" ${{filter.op === 'Contains' ? 'selected' : ''}}>contains</option>
517                        <option value="GreaterThan" ${{filter.op === 'GreaterThan' ? 'selected' : ''}}>></option>
518                        <option value="LessThan" ${{filter.op === 'LessThan' ? 'selected' : ''}}><</option>
519                    </select>
520                    <input type="text" placeholder="Value" value="${{filter.value}}"
521                           onchange="updateFilter(${{i}}, 'value', this.value)">
522                    <button class="btn btn-danger btn-small" onclick="removeFilter(${{i}})">ร—</button>
523                </div>
524            `).join('');
525            document.getElementById('filterList').innerHTML = html;
526        }}
527
528        function updateFilter(index, key, value) {{
529            filters[index][key] = value;
530            updateQueryPreview();
531        }}
532
533        function addSort() {{
534            sorting.push({{ field: '', direction: 'Ascending' }});
535            renderSorting();
536            updateQueryPreview();
537        }}
538
539        function removeSort(index) {{
540            sorting.splice(index, 1);
541            renderSorting();
542            updateQueryPreview();
543        }}
544
545        function renderSorting() {{
546            const html = sorting.map((sort, i) => `
547                <div class="sort-item">
548                    <select onchange="updateSort(${{i}}, 'field', this.value)">
549                        <option value="">Select field...</option>
550                        ${{Array.from(selectedFields).map(f =>
551                            `<option value="${{f}}" ${{sort.field === f ? 'selected' : ''}}>${{f}}</option>`
552                        ).join('')}}
553                    </select>
554                    <select onchange="updateSort(${{i}}, 'direction', this.value)">
555                        <option value="Ascending" ${{sort.direction === 'Ascending' ? 'selected' : ''}}>ASC</option>
556                        <option value="Descending" ${{sort.direction === 'Descending' ? 'selected' : ''}}>DESC</option>
557                    </select>
558                    <span></span>
559                    <button class="btn btn-danger btn-small" onclick="removeSort(${{i}})">ร—</button>
560                </div>
561            `).join('');
562            document.getElementById('sortList').innerHTML = html;
563        }}
564
565        function updateSort(index, key, value) {{
566            sorting[index][key] = value;
567            updateQueryPreview();
568        }}
569
570        function updateQueryPreview() {{
571            if (selectedFields.size === 0) {{
572                document.getElementById('queryPreview').textContent = 'Select fields to build your query';
573                return;
574            }}
575
576            const fields = Array.from(selectedFields).join('\\n  ');
577            let query = `query {{\\n  ${{fields}}\\n}}`;
578
579            document.getElementById('queryPreview').textContent = query;
580        }}
581
582        async function executeQuery() {{
583            const queryText = document.getElementById('queryPreview').textContent;
584            if (queryText === 'Select fields to build your query') {{
585                alert('Please select fields first');
586                return;
587            }}
588
589            document.getElementById('responseArea').innerHTML = '<div class="loading">Executing...</div>';
590
591            try {{
592                const response = await fetch(graphqlEndpoint, {{
593                    method: 'POST',
594                    headers: {{ 'Content-Type': 'application/json' }},
595                    body: JSON.stringify({{ query: queryText }})
596                }});
597
598                const result = await response.json();
599                document.getElementById('responseArea').innerHTML =
600                    `<pre class="code-preview">${{JSON.stringify(result, null, 2)}}</pre>`;
601            }} catch (error) {{
602                document.getElementById('responseArea').innerHTML =
603                    `<div class="error">Error: ${{error.message}}</div>`;
604            }}
605        }}
606
607        function copyQuery() {{
608            const query = document.getElementById('queryPreview').textContent;
609            navigator.clipboard.writeText(query);
610            alert('Query copied to clipboard!');
611        }}
612
613        function exportQuery() {{
614            const query = document.getElementById('queryPreview').textContent;
615            const blob = new Blob([query], {{ type: 'text/plain' }});
616            const url = URL.createObjectURL(blob);
617            const a = document.createElement('a');
618            a.href = url;
619            a.download = 'query.graphql';
620            a.click();
621        }}
622
623        function resetBuilder() {{
624            selectedFields.clear();
625            filters = [];
626            sorting = [];
627            document.getElementById('limitInput').value = '';
628            document.getElementById('offsetInput').value = '';
629            renderFieldTree();
630            renderFilters();
631            renderSorting();
632            updateQueryPreview();
633        }}
634
635        // Initialize
636        renderFieldTree();
637        renderFilters();
638        renderSorting();
639    </script>
640</body>
641</html>"#,
642        title = config.title,
643        graphql_endpoint = graphql_endpoint,
644        custom_css = custom_css
645    )
646}
647
648/// Query Builder manager
649#[derive(Debug)]
650pub struct QueryBuilder {
651    config: QueryBuilderConfig,
652}
653
654impl QueryBuilder {
655    /// Create new Query Builder
656    pub fn new(config: QueryBuilderConfig) -> Self {
657        Self { config }
658    }
659
660    /// Get HTML page for the builder
661    pub fn html(&self, graphql_endpoint: &str) -> String {
662        generate_query_builder_html(&self.config, graphql_endpoint)
663    }
664
665    /// Get configuration
666    pub fn config(&self) -> &QueryBuilderConfig {
667        &self.config
668    }
669}
670
671impl Default for QueryBuilder {
672    fn default() -> Self {
673        Self::new(QueryBuilderConfig::default())
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn test_query_builder_config_default() {
683        let config = QueryBuilderConfig::default();
684        assert!(config.enabled);
685        assert_eq!(config.endpoint, "/query-builder");
686        assert!(config.show_preview);
687        assert!(config.enable_execution);
688        assert!(config.enable_export);
689        assert_eq!(config.max_depth, 5);
690    }
691
692    #[test]
693    fn test_filter_op_display() {
694        assert_eq!(FilterOp::Equals.to_string(), "=");
695        assert_eq!(FilterOp::NotEquals.to_string(), "!=");
696        assert_eq!(FilterOp::GreaterThan.to_string(), ">");
697        assert_eq!(FilterOp::LessThan.to_string(), "<");
698        assert_eq!(FilterOp::Contains.to_string(), "contains");
699        assert_eq!(FilterOp::In.to_string(), "in");
700    }
701
702    #[test]
703    fn test_sort_direction_display() {
704        assert_eq!(SortDirection::Ascending.to_string(), "ASC");
705        assert_eq!(SortDirection::Descending.to_string(), "DESC");
706    }
707
708    #[test]
709    fn test_query_builder_state_default() {
710        let state = QueryBuilderState::default();
711        assert_eq!(state.operation_type, "query");
712        assert!(state.root_field.is_empty());
713        assert!(state.fields.is_empty());
714        assert!(state.filters.is_empty());
715        assert!(state.sorting.is_empty());
716        assert!(state.limit.is_none());
717        assert!(state.offset.is_none());
718    }
719
720    #[test]
721    fn test_field_selection() {
722        let field = FieldSelection {
723            name: "test".to_string(),
724            field_type: "String".to_string(),
725            selected: true,
726            arguments: HashMap::new(),
727            nested_fields: Vec::new(),
728        };
729
730        assert_eq!(field.name, "test");
731        assert_eq!(field.field_type, "String");
732        assert!(field.selected);
733    }
734
735    #[test]
736    fn test_filter_condition() {
737        let filter = FilterCondition {
738            field: "name".to_string(),
739            operation: FilterOp::Contains,
740            value: "test".to_string(),
741        };
742
743        assert_eq!(filter.field, "name");
744        assert_eq!(filter.operation, FilterOp::Contains);
745        assert_eq!(filter.value, "test");
746    }
747
748    #[test]
749    fn test_sort_field() {
750        let sort = SortField {
751            field: "createdAt".to_string(),
752            direction: SortDirection::Descending,
753        };
754
755        assert_eq!(sort.field, "createdAt");
756        assert_eq!(sort.direction, SortDirection::Descending);
757    }
758
759    #[test]
760    fn test_query_builder_creation() {
761        let builder = QueryBuilder::default();
762        assert!(builder.config().enabled);
763        assert!(builder.config().show_preview);
764    }
765
766    #[test]
767    fn test_generate_html() {
768        let config = QueryBuilderConfig::default();
769        let html = generate_query_builder_html(&config, "/graphql");
770
771        assert!(html.contains("<!DOCTYPE html>"));
772        assert!(html.contains("Query Builder"));
773        assert!(html.contains("/graphql"));
774        assert!(html.contains("Field Selection"));
775        assert!(html.contains("Query Configuration"));
776    }
777
778    #[test]
779    fn test_html_includes_features() {
780        let builder = QueryBuilder::default();
781        let html = builder.html("/graphql");
782
783        assert!(html.contains("Filters"));
784        assert!(html.contains("Sorting"));
785        assert!(html.contains("Pagination"));
786        assert!(html.contains("Generated Query"));
787    }
788}