Skip to content

Commit da6a4ff

Browse files
committed
feat: add RQL terminal to Web UI
1 parent a3666a6 commit da6a4ff

File tree

10 files changed

+348
-2
lines changed

10 files changed

+348
-2
lines changed

samples/Rules.Framework.WebUI.Sample/Program.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
namespace Rules.Framework.WebUI.Sample
22
{
3+
using global::Rules.Framework.IntegrationTests.Common.Scenarios;
4+
using global::Rules.Framework.IntegrationTests.Common.Scenarios.Scenario8;
35
using global::Rules.Framework.WebUI.Sample.Engine;
46
using global::Rules.Framework.WebUI.Sample.ReadmeExample;
57
using global::Rules.Framework.WebUI.Sample.Rules;
@@ -23,6 +25,16 @@ public static void Main(string[] args)
2325
}));
2426

2527
return await rulesProvider.GetRulesEngineAsync();
28+
})
29+
.AddInstance("Poker combinations example", async (_, _) =>
30+
{
31+
var rulesEngine = RulesEngineBuilder.CreateRulesEngine()
32+
.SetInMemoryDataSource()
33+
.Build();
34+
35+
await ScenarioLoader.LoadScenarioAsync(rulesEngine, new Scenario8Data());
36+
37+
return rulesEngine;
2638
});
2739
});
2840

samples/Rules.Framework.WebUI.Sample/Rules.Framework.WebUI.Sample.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<ItemGroup>
1818
<ProjectReference Include="..\..\src\Rules.Framework.WebUI\Rules.Framework.WebUI.csproj" />
1919
<ProjectReference Include="..\..\src\Rules.Framework\Rules.Framework.csproj" />
20+
<ProjectReference Include="..\..\tests\Rules.Framework.IntegrationTests.Common\Rules.Framework.IntegrationTests.Common.csproj" />
2021
</ItemGroup>
2122

2223
</Project>

src/Rules.Framework.WebUI/Assets/app.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,14 @@ h1:focus {
5454
position: relative;
5555
display: flex;
5656
flex-direction: column;
57+
width: 100%;
5758
}
5859

5960
main {
6061
max-height: calc(100vh - 3.5rem);
6162
height: calc(100vh - 3.5rem);
63+
padding-right: 0 !important;
64+
margin-right: 0 !important;
6265
}
6366

6467
.sidebar {

src/Rules.Framework.WebUI/Components/Layout/NavMenu.razor

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
</NavLink>
2626
</div>
2727

28+
<div class="nav-item">
29+
<NavLink class="nav-link" href="rules-ui/rql-terminal">
30+
<span class="px-3"><Icon Name="IconName.Code" /> RQL Terminal</span>
31+
</NavLink>
32+
</div>
33+
2834
@if (this.shouldRenderSwitchInstanceLink)
2935
{
3036
<div class="nav-item">
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
@attribute [ExcludeFromCodeCoverage]
2+
@page "/rules-ui/rql-terminal"
3+
@using Rules.Framework.Rql
4+
@using Rules.Framework.Rql.Runtime.Types
5+
@using System.Text
6+
@using System.Text.Json
7+
@rendermode InteractiveServer
8+
@inject IJSRuntime JS
9+
@inject WebUIOptions Options
10+
@inject IRulesEngineInstanceProvider RulesEngineInstanceProvider
11+
@inject ProtectedSessionStorage Storage
12+
13+
<PageTitle>@(this.Options.DocumentTitle) - RQL Terminal</PageTitle>
14+
15+
<h2>RQL Terminal</h2>
16+
17+
<div class="w-100" onclick="window.focusOnElement('commandInputTextbox')">
18+
<div class="terminal p-2 bg-dark rounded shadow-sm d-flex flex-column-reverse overflow-auto">
19+
<div class="terminal-text text-bg-dark d-flex">
20+
<span id="commandInputLabel" class="pe-2">></span>
21+
<input id="commandInputTextbox"
22+
type="text"
23+
class="terminal-input bg-dark text-bg-dark border-0 flex-fill"
24+
aria-describedby="commandInputLabel"
25+
@bind-value="commandInput"
26+
@onkeyup="OnCommandInputKeyUpAsync" />
27+
</div>
28+
<pre class="terminal-output terminal-text text-bg-dark" onclick="window.focusOnElement('commandInputTextbox')">
29+
@foreach (var line in this.outputQueue)
30+
{
31+
if (!string.IsNullOrWhiteSpace(line))
32+
{
33+
<code class="terminal-line mb-1" onclick="window.focusOnElement('commandInputTextbox')">@((MarkupString)line)</code>
34+
}
35+
<br class="mb-1" onclick="window.focusOnElement('commandInputTextbox')" />
36+
}
37+
</pre>
38+
</div>
39+
</div>
40+
41+
@code {
42+
private static readonly string tab = new string(' ', 4);
43+
private string commandInput;
44+
private LinkedList<string> commandInputHistory;
45+
private int commandInputHistoryCount;
46+
private LinkedListNode<string> commandInputHistoryCurrent;
47+
private Queue<string> outputQueue;
48+
private IRqlEngine rqlEngine;
49+
50+
protected override void OnInitialized()
51+
{
52+
this.outputQueue = new Queue<string>(this.Options.RqlTerminal.MaxOutputLines);
53+
this.commandInputHistory = new LinkedList<string>();
54+
this.commandInputHistoryCount = 0;
55+
}
56+
57+
protected override async Task OnAfterRenderAsync(bool firstRender)
58+
{
59+
if (firstRender)
60+
{
61+
var instanceIdResult = await this.Storage.GetAsync<Guid>(WebUIConstants.SelectedInstanceStorageKey);
62+
if (instanceIdResult.Success)
63+
{
64+
var instanceId = instanceIdResult.Value;
65+
var rulesEngineInstance = this.RulesEngineInstanceProvider.GetInstance(instanceId);
66+
this.rqlEngine = rulesEngineInstance.RulesEngine.GetRqlEngine();
67+
this.StateHasChanged();
68+
}
69+
}
70+
71+
await this.JS.InvokeVoidAsync("scrollToTop", ".terminal > pre");
72+
}
73+
74+
private async Task OnCommandInputKeyUpAsync(KeyboardEventArgs args)
75+
{
76+
switch (args.Key)
77+
{
78+
case "Enter":
79+
case "NumpadEnter":
80+
if (!string.IsNullOrWhiteSpace(this.commandInput))
81+
{
82+
await ExecuteAsync(this.rqlEngine, this.commandInput);
83+
84+
if (this.commandInputHistoryCount >= 50)
85+
{
86+
this.commandInputHistory.RemoveLast();
87+
}
88+
89+
this.commandInputHistory.AddFirst(this.commandInput);
90+
this.commandInput = string.Empty;
91+
this.commandInputHistoryCurrent = null;
92+
this.StateHasChanged();
93+
}
94+
break;
95+
96+
case "ArrowUp":
97+
if (this.commandInputHistoryCurrent is not null)
98+
{
99+
if (this.commandInputHistoryCurrent.Next is not null)
100+
{
101+
this.commandInputHistoryCurrent = this.commandInputHistoryCurrent.Next;
102+
this.commandInput = this.commandInputHistoryCurrent.Value;
103+
}
104+
}
105+
else
106+
{
107+
this.commandInputHistoryCurrent = this.commandInputHistory.First;
108+
if (this.commandInputHistoryCurrent is not null)
109+
{
110+
this.commandInput = this.commandInputHistoryCurrent.Value;
111+
}
112+
}
113+
break;
114+
115+
case "ArrowDown":
116+
if (this.commandInputHistoryCurrent is not null)
117+
{
118+
if (this.commandInputHistoryCurrent.Previous is not null)
119+
{
120+
this.commandInputHistoryCurrent = this.commandInputHistoryCurrent.Previous;
121+
this.commandInput = this.commandInputHistoryCurrent.Value;
122+
}
123+
else
124+
{
125+
this.commandInput = string.Empty;
126+
}
127+
}
128+
break;
129+
130+
default:
131+
break;
132+
}
133+
}
134+
135+
private async Task ExecuteAsync(IRqlEngine rqlEngine, string? input)
136+
{
137+
try
138+
{
139+
WriteLine($"> {input}");
140+
var results = await rqlEngine.ExecuteAsync(input);
141+
foreach (var result in results)
142+
{
143+
switch (result)
144+
{
145+
case RulesSetResult rulesResultSet:
146+
HandleRulesSetResult(rulesResultSet);
147+
break;
148+
149+
case NothingResult:
150+
// Nothing to be done.
151+
break;
152+
153+
case ValueResult valueResult:
154+
HandleObjectResult(valueResult);
155+
break;
156+
157+
default:
158+
throw new NotSupportedException($"Result type is not supported: '{result.GetType().FullName}'");
159+
}
160+
}
161+
}
162+
catch (RqlException rqlException)
163+
{
164+
WriteLine($"{rqlException.Message} Errors:");
165+
166+
foreach (var rqlError in rqlException.Errors)
167+
{
168+
var errorMessageBuilder = new StringBuilder(" - ")
169+
.Append(rqlError.Text)
170+
.Append(" @")
171+
.Append(rqlError.BeginPosition)
172+
.Append('-')
173+
.Append(rqlError.EndPosition);
174+
WriteLine(errorMessageBuilder.ToString());
175+
}
176+
}
177+
178+
WriteLine();
179+
}
180+
181+
private void HandleObjectResult(ValueResult result)
182+
{
183+
WriteLine();
184+
var rawValue = result.Value switch
185+
{
186+
RqlAny rqlAny when rqlAny.UnderlyingType == RqlTypes.Object => rqlAny.ToString() ?? string.Empty,
187+
RqlAny rqlAny => rqlAny.ToString() ?? string.Empty,
188+
_ => result.Value.ToString(),
189+
};
190+
var value = rawValue!.Replace("\n", $"\n{tab}");
191+
WriteLine($"{tab}{value}");
192+
}
193+
194+
private void HandleRulesSetResult(RulesSetResult result)
195+
{
196+
WriteLine();
197+
if (result.Lines.Any())
198+
{
199+
WriteLine($"{tab}{result.Rql}");
200+
WriteLine($"{tab}{new string('-', Math.Min(result.Rql.Length, 20))}");
201+
if (result.NumberOfRules > 0)
202+
{
203+
WriteLine($"{tab} {result.NumberOfRules} rules were returned.");
204+
}
205+
else
206+
{
207+
WriteLine($"{tab} {result.Lines.Count} rules were returned.");
208+
}
209+
210+
WriteLine();
211+
WriteLine($"{tab} | # | Priority | Status | Range | Rule");
212+
WriteLine($"{tab}{new string('-', 20)}");
213+
214+
foreach (var line in result.Lines)
215+
{
216+
var rule = line.Rule.Value;
217+
var lineNumber = line.LineNumber.ToString();
218+
var priority = rule.Priority.ToString();
219+
var active = rule.Active ? "Active" : "Inactive";
220+
var dateBegin = rule.DateBegin.Date.ToString("yyyy-MM-ddZ");
221+
var dateEnd = rule.DateEnd?.Date.ToString("yyyy-MM-ddZ") ?? "(no end)";
222+
var ruleName = rule.Name;
223+
var content = JsonSerializer.Serialize(rule.ContentContainer.GetContentAs<object>());
224+
225+
WriteLine($"{tab} | {lineNumber} | {priority,-8} | {active,-8} | {dateBegin,-11} - {dateEnd,-11} | {ruleName}: {content}");
226+
}
227+
}
228+
else if (result.NumberOfRules > 0)
229+
{
230+
WriteLine($"{tab}{result.Rql}");
231+
WriteLine($"{tab}{new string('-', result.Rql.Length)}");
232+
WriteLine($"{tab} {result.NumberOfRules} rules were affected.");
233+
}
234+
else
235+
{
236+
WriteLine($"{tab}{result.Rql}");
237+
WriteLine($"{tab}{new string('-', result.Rql.Length)}");
238+
WriteLine($"{tab} (empty)");
239+
}
240+
}
241+
242+
private void WriteLine(string? output = null)
243+
{
244+
if (output is not null)
245+
{
246+
var linesSplitOutput = output.Split('\n');
247+
foreach (var line in linesSplitOutput)
248+
{
249+
this.outputQueue.Enqueue(line);
250+
}
251+
}
252+
else
253+
{
254+
this.outputQueue.Enqueue(string.Empty);
255+
}
256+
257+
while (this.outputQueue.Count >= this.Options.RqlTerminal.MaxOutputLines)
258+
{
259+
this.outputQueue.Dequeue();
260+
}
261+
}
262+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.terminal {
2+
height: 80vh !important;
3+
scroll-behavior: smooth;
4+
}
5+
6+
.terminal-input {
7+
outline: none;
8+
caret-color: rgb(255, 255, 255);
9+
caret-shape: bar;
10+
}
11+
12+
.terminal-line {
13+
font-size: 1rem;
14+
width: 100%;
15+
word-wrap: break-word;
16+
}
17+
18+
.terminal-output {
19+
display: flex;
20+
flex-direction: column;
21+
white-space: pre-wrap;
22+
}
23+
24+
.terminal-text {
25+
font-family: var(--bs-font-monospace);
26+
font-size: 1rem !important;
27+
}

src/Rules.Framework.WebUI/Components/Pages/SearchRules.razor

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
@using System.IO
77
@using System.Text
88
@inject WebUIOptions Options
9-
@inject IJSRuntime JS
10-
@inject NavigationManager NavigationManager
119
@inject IRulesEngineInstanceProvider RulesEngineInstanceProvider
1210
@inject ProtectedSessionStorage Storage
1311

src/Rules.Framework.WebUI/Components/WebUIApp.razor

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<base href="/" />
99
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
1010
<link href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css" rel="stylesheet" />
11+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css">
1112
<link href="_content/Blazor.Bootstrap/blazor.bootstrap.css" rel="stylesheet" />
1213
<link rel="stylesheet" href="rules-ui/glyphicons-only-bootstrap/css/bootstrap.css" />
1314
<link rel="stylesheet" href="rules-ui/app.css" />
@@ -21,6 +22,7 @@
2122
<Routes />
2223
<script src="_framework/blazor.web.js"></script>
2324
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
25+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
2426
<script src="_content/Blazor.Bootstrap/blazor.bootstrap.js"></script>
2527
<script>
2628
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
@@ -34,6 +36,19 @@
3436
anchorElement.remove();
3537
URL.revokeObjectURL(url);
3638
}
39+
40+
window.scrollToTop = (selector) => {
41+
let element = document.querySelector(selector);
42+
element.scrollTo(0, element.scrollHeight);
43+
}
44+
45+
window.focusOnElement = (id) => {
46+
var selection = window.getSelection();
47+
if (selection.type != "Range") {
48+
document.getElementById(id).focus();
49+
return false;
50+
}
51+
}
3752
</script>
3853
</body>
3954

0 commit comments

Comments
 (0)