A2UI parity: restore chart-type guidance in system prompt, add detailed schemas for form fields, card actions, tab content, and mindmap nodes
This commit is contained in:
@@ -272,6 +272,129 @@
|
|||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stacked bars: segments sit side by side inside the track. */
|
||||||
|
.chat-surface-chart-bar-stacked {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-bar-segment {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared legend (pie/donut/stacked-bar). */
|
||||||
|
.chat-surface-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-legend-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pie / donut. */
|
||||||
|
.chat-surface-chart-pie {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-pie-svg {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-pie-slice {
|
||||||
|
stroke: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-donut-hole {
|
||||||
|
fill: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-donut-total {
|
||||||
|
fill: var(--vscode-editor-foreground);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line / area. */
|
||||||
|
.chat-surface-chart-line-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-grid {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-y-label,
|
||||||
|
.chat-surface-chart-line-x-label {
|
||||||
|
fill: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-path {
|
||||||
|
stroke: var(--accent-color);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-area-fill {
|
||||||
|
fill: var(--accent-color);
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-dot {
|
||||||
|
fill: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heatmap. */
|
||||||
|
.chat-surface-chart-heatmap {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-col-label,
|
||||||
|
.chat-surface-chart-heatmap-row-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-col-label {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Card surface ──────────────────────────────────────────────────── */
|
/* ── Card surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.chat-surface-card {
|
.chat-surface-card {
|
||||||
|
|||||||
@@ -786,15 +786,16 @@ defmodule BDS.AI.Chat do
|
|||||||
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
|
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
|
||||||
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
|
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
|
||||||
"",
|
"",
|
||||||
"Available UI Render Tools:",
|
"Available UI Render Tools (use these to show rich interactive elements):",
|
||||||
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.",
|
"- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. IMPORTANT: a heatmap needs structured data — each entry in 'series' is a ROW and must include a 'segments' array whose entries are the COLUMNS (every segment needs a 'label' and a numeric 'value'); the row's own 'value' is ignored. Plan what the rows and columns represent before fetching data (e.g. rows = years, columns = months). A heatmap sent without segments renders empty.",
|
||||||
"- Use render_table for tabular data, comparisons, and structured listings.",
|
"- render_table: Show data in a structured table. Use for tabular comparisons and listings.",
|
||||||
"- Use render_form to collect structured user input.",
|
"- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).",
|
||||||
"- Use render_card for summaries, highlights, or actionable items.",
|
"- render_card: Show an information card with title, body, and action buttons.",
|
||||||
"- Use render_metric for a single KPI or important statistic.",
|
"- render_metric: Show a single KPI or statistic prominently.",
|
||||||
"- Use render_list for bullet lists, checklists, or simple enumerations.",
|
"- render_list: Show a bulleted list of items.",
|
||||||
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.",
|
"- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.",
|
||||||
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data."
|
"",
|
||||||
|
"When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. When you need user input for a multi-field operation, use render_form to present a structured form. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media). When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab. When building any visualization, render it as soon as you have enough data."
|
||||||
],
|
],
|
||||||
"\n"
|
"\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -803,10 +803,40 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Optional form title"},
|
||||||
"fields" => %{"type" => "array"},
|
"fields" => %{
|
||||||
"submitLabel" => %{"type" => "string"},
|
"type" => "array",
|
||||||
"submitAction" => %{"type" => "string"}
|
"description" => "Form fields to display",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"key" => %{"type" => "string", "description" => "Field identifier"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Field label shown to user"},
|
||||||
|
"inputType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
|
||||||
|
"description" => "Type of input control"
|
||||||
|
},
|
||||||
|
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
|
||||||
|
"defaultValue" => %{"type" => "string", "description" => "Default value"},
|
||||||
|
"options" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Options for select fields",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string"},
|
||||||
|
"value" => %{"type" => "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
|
||||||
|
},
|
||||||
|
"required" => ["key", "label", "inputType"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
|
||||||
|
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -815,10 +845,25 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Card title"},
|
||||||
"subtitle" => %{"type" => "string"},
|
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
|
||||||
"body" => %{"type" => "string"},
|
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
|
||||||
"actions" => %{"type" => "array"}
|
"actions" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Optional action buttons on the card",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Button label"},
|
||||||
|
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
|
||||||
|
"payload" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"description" => "Optional action payload"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label", "action"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -827,8 +872,8 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"label" => %{"type" => "string"},
|
"label" => %{"type" => "string", "description" => "Metric label"},
|
||||||
"value" => %{"type" => "string"}
|
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -837,8 +882,12 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Optional list title"},
|
||||||
"items" => %{"type" => "array"}
|
"items" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "List items"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -847,8 +896,58 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Optional tabs title"},
|
||||||
"tabs" => %{"type" => "array"}
|
"tabs" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Array of tabs",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Tab label"},
|
||||||
|
"content" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Content items within the tab",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"type" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["text", "metric", "list", "chart", "table"],
|
||||||
|
"description" => "Content type"
|
||||||
|
},
|
||||||
|
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
|
||||||
|
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
|
||||||
|
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
|
||||||
|
"items" => %{
|
||||||
|
"type" => "array", "items" => %{"type" => "string"},
|
||||||
|
"description" => "Items (for type list)"
|
||||||
|
},
|
||||||
|
"chartType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||||
|
"description" => "Chart type (for type chart)"
|
||||||
|
},
|
||||||
|
"series" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Data series (for type chart)"
|
||||||
|
},
|
||||||
|
"columns" => %{
|
||||||
|
"type" => "array", "items" => %{"type" => "string"},
|
||||||
|
"description" => "Column headers (for type table)"
|
||||||
|
},
|
||||||
|
"rows" => %{
|
||||||
|
"type" => "array", "items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"description" => "Table rows (for type table)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["type"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label", "content"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -857,8 +956,24 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Optional mind map title"},
|
||||||
"nodes" => %{"type" => "array"}
|
"nodes" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"id" => %{"type" => "string", "description" => "Unique node identifier"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Node label text"},
|
||||||
|
"children" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "IDs of child nodes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["id", "label"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -684,6 +684,61 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
<h3><%= @surface.title %></h3>
|
<h3><%= @surface.title %></h3>
|
||||||
<% end %>
|
<% end %>
|
||||||
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
|
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
|
||||||
|
<%= case @surface.chart_type do %>
|
||||||
|
<% chart_type when chart_type in ["pie", "donut"] -> %>
|
||||||
|
<% pie = BDS.Desktop.ShellLive.ChatEditor.ChartView.pie(@surface.series) %>
|
||||||
|
<div class="chat-surface-chart-pie">
|
||||||
|
<svg class="chat-surface-chart-pie-svg" viewBox={"0 0 #{pie.size} #{pie.size}"} preserveAspectRatio="xMidYMid meet">
|
||||||
|
<%= for slice <- pie.slices do %>
|
||||||
|
<%= if slice.full_circle do %>
|
||||||
|
<circle class="chat-surface-chart-pie-slice" cx={pie.center} cy={pie.center} r={pie.radius} fill={slice.color}>
|
||||||
|
<title><%= slice.label %>: <%= slice.value %></title>
|
||||||
|
</circle>
|
||||||
|
<% else %>
|
||||||
|
<path class="chat-surface-chart-pie-slice" d={slice.d} fill={slice.color}>
|
||||||
|
<title><%= slice.label %>: <%= slice.value %></title>
|
||||||
|
</path>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<%= if @surface.chart_type == "donut" do %>
|
||||||
|
<circle class="chat-surface-chart-donut-hole" cx={pie.center} cy={pie.center} r={pie.donut_inner} />
|
||||||
|
<text class="chat-surface-chart-donut-total" x={pie.center} y={pie.center} text-anchor="middle" dominant-baseline="central"><%= pie.total %></text>
|
||||||
|
<% end %>
|
||||||
|
</svg>
|
||||||
|
<div class="chat-surface-chart-legend">
|
||||||
|
<%= for item <- pie.legend do %>
|
||||||
|
<span class="chat-surface-chart-legend-item">
|
||||||
|
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||||
|
<%= item.label %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% chart_type when chart_type in ["line", "area"] -> %>
|
||||||
|
<% line = BDS.Desktop.ShellLive.ChatEditor.ChartView.line(@surface.series, @surface.chart_type == "area") %>
|
||||||
|
<svg class="chat-surface-chart-line-svg" viewBox={line.view_box} preserveAspectRatio="xMidYMid meet">
|
||||||
|
<%= for tick <- line.grid do %>
|
||||||
|
<line class="chat-surface-chart-line-grid" x1={tick.x1} y1={tick.y} x2={tick.x2} y2={tick.y} />
|
||||||
|
<text class="chat-surface-chart-line-y-label" x={tick.label_x} y={tick.y} text-anchor="end" dominant-baseline="middle"><%= tick.label %></text>
|
||||||
|
<% end %>
|
||||||
|
<%= if line.area? do %>
|
||||||
|
<polygon class="chat-surface-chart-area-fill" points={line.area_points} />
|
||||||
|
<% end %>
|
||||||
|
<polyline class="chat-surface-chart-line-path" points={line.polyline} fill="none" />
|
||||||
|
<%= for dot <- line.dots do %>
|
||||||
|
<circle class="chat-surface-chart-line-dot" cx={dot.x} cy={dot.y} r="3">
|
||||||
|
<title><%= dot.label %>: <%= dot.value %></title>
|
||||||
|
</circle>
|
||||||
|
<% end %>
|
||||||
|
<%= for label <- line.x_labels do %>
|
||||||
|
<text class="chat-surface-chart-line-x-label" x={label.x} y={label.y} text-anchor="middle"><%= label.label %></text>
|
||||||
|
<% end %>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<% "heatmap" -> %>
|
||||||
|
<% heat = BDS.Desktop.ShellLive.ChatEditor.ChartView.heatmap(@surface.series) %>
|
||||||
|
<%= if heat.rows == [] do %>
|
||||||
<div class="chat-surface-chart-list">
|
<div class="chat-surface-chart-list">
|
||||||
<%= for series <- @surface.series do %>
|
<%= for series <- @surface.series do %>
|
||||||
<div class="chat-surface-chart-row">
|
<div class="chat-surface-chart-row">
|
||||||
@@ -697,6 +752,62 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="chat-surface-chart-heatmap" style={"grid-template-columns: auto repeat(#{heat.column_count}, 1fr)"}>
|
||||||
|
<span class="chat-surface-chart-heatmap-corner"></span>
|
||||||
|
<%= for col <- heat.columns do %>
|
||||||
|
<span class="chat-surface-chart-heatmap-col-label"><%= col %></span>
|
||||||
|
<% end %>
|
||||||
|
<%= for row <- heat.rows do %>
|
||||||
|
<span class="chat-surface-chart-heatmap-row-label"><%= row.label %></span>
|
||||||
|
<%= for cell <- row.cells do %>
|
||||||
|
<span class="chat-surface-chart-heatmap-cell" style={"background: #{cell.bg}; color: #{cell.fg}"} title={cell.value}><%= if cell.value > 0, do: cell.value %></span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% "stacked-bar" -> %>
|
||||||
|
<% stacked = BDS.Desktop.ShellLive.ChatEditor.ChartView.stacked(@surface.series) %>
|
||||||
|
<div class="chat-surface-chart-list">
|
||||||
|
<%= for row <- stacked.rows do %>
|
||||||
|
<div class="chat-surface-chart-row">
|
||||||
|
<div class="chat-surface-chart-meta">
|
||||||
|
<span><%= row.label %></span>
|
||||||
|
<span><%= row.total %></span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-surface-chart-bar chat-surface-chart-bar-stacked">
|
||||||
|
<%= for seg <- row.segments do %>
|
||||||
|
<span class="chat-surface-chart-bar-segment" style={"width: #{seg.width}%; background-color: #{seg.color}"} title={"#{seg.label}: #{seg.value}"}></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="chat-surface-chart-legend">
|
||||||
|
<%= for item <- stacked.legend do %>
|
||||||
|
<span class="chat-surface-chart-legend-item">
|
||||||
|
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||||
|
<%= item.label %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% _bar -> %>
|
||||||
|
<div class="chat-surface-chart-list">
|
||||||
|
<%= for series <- @surface.series do %>
|
||||||
|
<div class="chat-surface-chart-row">
|
||||||
|
<div class="chat-surface-chart-meta">
|
||||||
|
<span><%= series.label %></span>
|
||||||
|
<span><%= series.value %></span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-surface-chart-bar">
|
||||||
|
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% "metric" -> %>
|
<% "metric" -> %>
|
||||||
<div class="chat-surface-metric">
|
<div class="chat-surface-metric">
|
||||||
|
|||||||
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ChatEditor.ChartView do
|
||||||
|
@moduledoc """
|
||||||
|
Pure geometry/view helpers for rendering a2ui chart surfaces.
|
||||||
|
|
||||||
|
Mirrors the behaviour of the legacy bDS `A2UIChart` React component so the
|
||||||
|
Elixir rewrite supports the same chart types (`bar`, `stacked-bar`, `line`,
|
||||||
|
`area`, `pie`, `donut`, `heatmap`) with the same visual output. Each function
|
||||||
|
returns plain maps/lists that a HEEx template can iterate over; no markup is
|
||||||
|
produced here so the geometry stays unit-testable.
|
||||||
|
|
||||||
|
Series entries are expected to be maps with `:label`, `:value` and an optional
|
||||||
|
list of `:segments` (each a map with `:label` and `:value`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Matches the legacy bDS SEGMENT_COLORS palette.
|
||||||
|
@palette ["#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2e210"]
|
||||||
|
|
||||||
|
# Line/area chart geometry (viewBox units), matching the legacy component.
|
||||||
|
@line_width 300
|
||||||
|
@line_height 140
|
||||||
|
@pad_top 8
|
||||||
|
@pad_right 12
|
||||||
|
@pad_bottom 24
|
||||||
|
@pad_left 40
|
||||||
|
|
||||||
|
@doc "Returns the colour for the series/segment at `index` (cycles the palette)."
|
||||||
|
@spec color(integer()) :: String.t()
|
||||||
|
def color(index), do: Enum.at(@palette, rem(index, length(@palette)))
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Pie/donut geometry: SVG slices, a legend and the running total.
|
||||||
|
"""
|
||||||
|
@spec pie([map()]) :: map()
|
||||||
|
def pie(series) do
|
||||||
|
total = series |> Enum.map(& &1.value) |> Enum.sum()
|
||||||
|
center = 70
|
||||||
|
radius = 56
|
||||||
|
|
||||||
|
{slices, _angle} =
|
||||||
|
if total > 0 do
|
||||||
|
series
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map_reduce(0.0, fn {entry, i}, current ->
|
||||||
|
slice_angle = entry.value / total * 360
|
||||||
|
end_angle = current + slice_angle
|
||||||
|
|
||||||
|
slice =
|
||||||
|
if slice_angle >= 359.99 do
|
||||||
|
%{full_circle: true, d: nil, color: color(i), label: entry.label, value: entry.value}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
full_circle: false,
|
||||||
|
d: describe_slice(center, radius, current, end_angle),
|
||||||
|
color: color(i),
|
||||||
|
label: entry.label,
|
||||||
|
value: entry.value
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{slice, end_angle}
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
{[], 0.0}
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
size: 140,
|
||||||
|
center: center,
|
||||||
|
radius: radius,
|
||||||
|
donut_inner: radius * 0.58,
|
||||||
|
total: total,
|
||||||
|
slices: slices,
|
||||||
|
legend: legend(series)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Line/area geometry: gridlines + Y labels, the polyline points, an optional
|
||||||
|
area polygon, the data dots and the X-axis labels.
|
||||||
|
"""
|
||||||
|
@spec line([map()], boolean()) :: map()
|
||||||
|
def line(series, area?) do
|
||||||
|
plot_width = @line_width - @pad_left - @pad_right
|
||||||
|
plot_height = @line_height - @pad_top - @pad_bottom
|
||||||
|
max_value = Enum.max([0 | Enum.map(series, & &1.value)])
|
||||||
|
ticks = y_ticks(max_value)
|
||||||
|
y_max = List.last(ticks)
|
||||||
|
len = length(series)
|
||||||
|
x_step = if len > 1, do: plot_width / (len - 1), else: 0.0
|
||||||
|
|
||||||
|
points =
|
||||||
|
series
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {entry, i} ->
|
||||||
|
x = @pad_left + if(len > 1, do: i * x_step, else: plot_width / 2)
|
||||||
|
y = @pad_top + if(y_max > 0, do: (1 - entry.value / y_max) * plot_height, else: plot_height)
|
||||||
|
%{x: x, y: y, label: entry.label, value: entry.value}
|
||||||
|
end)
|
||||||
|
|
||||||
|
polyline = Enum.map_join(points, " ", fn p -> "#{fmt(p.x)},#{fmt(p.y)}" end)
|
||||||
|
baseline = @pad_top + plot_height
|
||||||
|
|
||||||
|
area_points =
|
||||||
|
case {area?, points} do
|
||||||
|
{true, [_ | _]} ->
|
||||||
|
first = List.first(points)
|
||||||
|
last = List.last(points)
|
||||||
|
"#{fmt(first.x)},#{fmt(baseline)} #{polyline} #{fmt(last.x)},#{fmt(baseline)}"
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
grid =
|
||||||
|
Enum.map(ticks, fn tick ->
|
||||||
|
y = @pad_top + if(y_max > 0, do: (1 - tick / y_max) * plot_height, else: plot_height)
|
||||||
|
|
||||||
|
%{
|
||||||
|
y: fmt(y),
|
||||||
|
x1: fmt(@pad_left),
|
||||||
|
x2: fmt(@pad_left + plot_width),
|
||||||
|
label: fmt(tick),
|
||||||
|
label_x: fmt(@pad_left - 4)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
view_box: "0 0 #{@line_width} #{@line_height}",
|
||||||
|
area?: area?,
|
||||||
|
area_points: area_points,
|
||||||
|
polyline: polyline,
|
||||||
|
grid: grid,
|
||||||
|
dots: Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(p.y), label: p.label, value: p.value} end),
|
||||||
|
x_labels:
|
||||||
|
Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(@line_height - 4), label: p.label} end)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Heatmap grid: column labels plus rows of colour-scaled cells. Each series
|
||||||
|
entry is a row and each of its segments is a column.
|
||||||
|
"""
|
||||||
|
@spec heatmap([map()]) :: map()
|
||||||
|
def heatmap(series) do
|
||||||
|
rows_src = Enum.filter(series, &(&1.segments != []))
|
||||||
|
columns = rows_src |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||||
|
|
||||||
|
global_max =
|
||||||
|
case Enum.flat_map(rows_src, fn e -> Enum.map(e.segments, & &1.value) end) do
|
||||||
|
[] -> 0
|
||||||
|
values -> Enum.max(values)
|
||||||
|
end
|
||||||
|
|
||||||
|
rows =
|
||||||
|
Enum.map(rows_src, fn entry ->
|
||||||
|
seg_map = Map.new(entry.segments, fn s -> {s.label, s.value} end)
|
||||||
|
|
||||||
|
cells =
|
||||||
|
Enum.map(columns, fn col ->
|
||||||
|
value = Map.get(seg_map, col, 0)
|
||||||
|
alpha = if global_max > 0, do: value / global_max, else: 0.0
|
||||||
|
{bg, fg} = cell_colors(alpha)
|
||||||
|
%{value: value, bg: bg, fg: fg}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{label: entry.label, cells: cells}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{columns: columns, column_count: length(columns), rows: rows}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Stacked-bar geometry: each row's segments with widths and colours, plus a
|
||||||
|
legend of the unique segment labels.
|
||||||
|
"""
|
||||||
|
@spec stacked([map()]) :: map()
|
||||||
|
def stacked(series) do
|
||||||
|
seg_labels =
|
||||||
|
series |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||||
|
|
||||||
|
totals =
|
||||||
|
Enum.map(series, fn e ->
|
||||||
|
if e.segments == [], do: e.value, else: Enum.sum(Enum.map(e.segments, & &1.value))
|
||||||
|
end)
|
||||||
|
|
||||||
|
max_total = Enum.max([0 | totals])
|
||||||
|
|
||||||
|
rows =
|
||||||
|
series
|
||||||
|
|> Enum.zip(totals)
|
||||||
|
|> Enum.map(fn {entry, total} ->
|
||||||
|
segments =
|
||||||
|
Enum.map(entry.segments, fn s ->
|
||||||
|
width = if max_total > 0, do: s.value / max_total * 100, else: 0.0
|
||||||
|
|
||||||
|
%{
|
||||||
|
label: s.label,
|
||||||
|
value: s.value,
|
||||||
|
width: fmt(width),
|
||||||
|
color: color(Enum.find_index(seg_labels, &(&1 == s.label)) || 0)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{label: entry.label, total: total, segments: segments}
|
||||||
|
end)
|
||||||
|
|
||||||
|
legend = seg_labels |> Enum.with_index() |> Enum.map(fn {l, i} -> %{label: l, color: color(i)} end)
|
||||||
|
|
||||||
|
%{rows: rows, legend: legend, max_total: max_total}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Nice, rounded Y-axis tick values for `max_value`. Always starts at 0 and ends
|
||||||
|
at or above `max_value`.
|
||||||
|
"""
|
||||||
|
@spec y_ticks(number(), pos_integer()) :: [number()]
|
||||||
|
def y_ticks(max_value, tick_count \\ 4)
|
||||||
|
def y_ticks(max_value, _tick_count) when max_value <= 0, do: [0]
|
||||||
|
|
||||||
|
def y_ticks(max_value, tick_count) do
|
||||||
|
raw_step = max_value / (tick_count - 1)
|
||||||
|
magnitude = :math.pow(10, Float.floor(:math.log10(raw_step)))
|
||||||
|
normalised = raw_step / magnitude
|
||||||
|
|
||||||
|
nice_step =
|
||||||
|
cond do
|
||||||
|
normalised <= 1 -> magnitude
|
||||||
|
normalised <= 2 -> 2 * magnitude
|
||||||
|
normalised <= 5 -> 5 * magnitude
|
||||||
|
true -> 10 * magnitude
|
||||||
|
end
|
||||||
|
|
||||||
|
ticks =
|
||||||
|
0.0
|
||||||
|
|> Stream.iterate(&(&1 + nice_step))
|
||||||
|
|> Enum.take_while(&(&1 <= max_value + nice_step * 0.01))
|
||||||
|
|> Enum.map(&Float.round(&1, 6))
|
||||||
|
|
||||||
|
if length(ticks) < 2, do: ticks ++ [Float.round(nice_step, 6)], else: ticks
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── private helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp legend(series) do
|
||||||
|
series |> Enum.with_index() |> Enum.map(fn {e, i} -> %{label: e.label, color: color(i)} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp describe_slice(center, radius, start_angle, end_angle) do
|
||||||
|
start_rad = (start_angle - 90) * :math.pi() / 180
|
||||||
|
end_rad = (end_angle - 90) * :math.pi() / 180
|
||||||
|
x1 = center + radius * :math.cos(start_rad)
|
||||||
|
y1 = center + radius * :math.sin(start_rad)
|
||||||
|
x2 = center + radius * :math.cos(end_rad)
|
||||||
|
y2 = center + radius * :math.sin(end_rad)
|
||||||
|
large_arc = if end_angle - start_angle > 180, do: 1, else: 0
|
||||||
|
|
||||||
|
"M#{fmt(center)},#{fmt(center)} L#{fmt(x1)},#{fmt(y1)} " <>
|
||||||
|
"A#{fmt(radius)},#{fmt(radius)} 0 #{large_arc} 1 #{fmt(x2)},#{fmt(y2)} Z"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cell_colors(alpha) when alpha <= 0, do: {"transparent", "inherit"}
|
||||||
|
|
||||||
|
defp cell_colors(alpha) do
|
||||||
|
{r, g, b} = lerp_rgb({53, 117, 56}, {183, 72, 72}, alpha)
|
||||||
|
opacity = 0.25 + alpha * 0.75
|
||||||
|
bg = "rgba(#{r},#{g},#{b},#{Float.round(opacity, 2)})"
|
||||||
|
er = round(r * opacity + 30 * (1 - opacity))
|
||||||
|
eg = round(g * opacity + 30 * (1 - opacity))
|
||||||
|
eb = round(b * opacity + 30 * (1 - opacity))
|
||||||
|
fg = if 0.299 * er + 0.587 * eg + 0.114 * eb > 140, do: "#000", else: "#fff"
|
||||||
|
{bg, fg}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lerp_rgb({ar, ag, ab}, {br, bg, bb}, t) do
|
||||||
|
{round(ar + (br - ar) * t), round(ag + (bg - ag) * t), round(ab + (bb - ab) * t)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fmt(n) when is_integer(n), do: Integer.to_string(n)
|
||||||
|
|
||||||
|
defp fmt(n) when is_float(n) do
|
||||||
|
rounded = Float.round(n, 2)
|
||||||
|
if rounded == Float.round(rounded, 0), do: Integer.to_string(trunc(rounded)), else: :erlang.float_to_binary(rounded, [:short])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -87,7 +87,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
|||||||
%{
|
%{
|
||||||
label: map_value(entry, "label", dgettext("ui", "Assistant")),
|
label: map_value(entry, "label", dgettext("ui", "Assistant")),
|
||||||
value: numeric_value(map_value(entry, "value", 0)),
|
value: numeric_value(map_value(entry, "value", 0)),
|
||||||
segments: List.wrap(map_value(entry, "segments", []))
|
segments:
|
||||||
|
entry
|
||||||
|
|> map_value("segments", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.map(fn segment ->
|
||||||
|
%{
|
||||||
|
label: map_value(segment, "label", ""),
|
||||||
|
value: numeric_value(map_value(segment, "value", 0))
|
||||||
|
}
|
||||||
|
end)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
129
test/bds/desktop/shell_live/chat_editor/chart_view_test.exs
Normal file
129
test/bds/desktop/shell_live/chat_editor/chart_view_test.exs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
defmodule Bds.Desktop.ShellLive.ChatEditor.ChartViewTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellLive.ChatEditor.ChartView
|
||||||
|
|
||||||
|
defp entry(label, value, segments \\ []) do
|
||||||
|
%{label: label, value: value, segments: segments}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seg(label, value), do: %{label: label, value: value}
|
||||||
|
|
||||||
|
describe "y_ticks/1" do
|
||||||
|
test "always starts at zero and reaches the max value" do
|
||||||
|
ticks = ChartView.y_ticks(100)
|
||||||
|
assert List.first(ticks) == 0.0
|
||||||
|
assert List.last(ticks) >= 100
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns [0] for non-positive maxima" do
|
||||||
|
assert ChartView.y_ticks(0) == [0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "produces nice rounded steps" do
|
||||||
|
assert ChartView.y_ticks(100) == [0.0, 50.0, 100.0]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "pie/1" do
|
||||||
|
test "produces one slice and legend item per series entry" do
|
||||||
|
result = ChartView.pie([entry("A", 1), entry("B", 3)])
|
||||||
|
assert length(result.slices) == 2
|
||||||
|
assert length(result.legend) == 2
|
||||||
|
assert result.total == 4
|
||||||
|
assert [%{d: "M" <> _}, _] = result.slices
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses a full circle for a single slice" do
|
||||||
|
result = ChartView.pie([entry("Only", 5)])
|
||||||
|
assert [%{full_circle: true, d: nil}] = result.slices
|
||||||
|
end
|
||||||
|
|
||||||
|
test "distinct colours come from the palette" do
|
||||||
|
result = ChartView.pie([entry("A", 1), entry("B", 1)])
|
||||||
|
colours = Enum.map(result.slices, & &1.color)
|
||||||
|
assert colours == Enum.uniq(colours)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty series yields no slices" do
|
||||||
|
assert %{slices: [], total: 0} = ChartView.pie([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "line/2" do
|
||||||
|
test "produces a dot and polyline point per entry" do
|
||||||
|
result = ChartView.line([entry("Jan", 10), entry("Feb", 20)], false)
|
||||||
|
assert length(result.dots) == 2
|
||||||
|
assert result.polyline =~ ","
|
||||||
|
assert result.area? == false
|
||||||
|
assert result.area_points == ""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "area mode builds a closed polygon" do
|
||||||
|
result = ChartView.line([entry("Jan", 10), entry("Feb", 20)], true)
|
||||||
|
assert result.area? == true
|
||||||
|
assert result.area_points != ""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "gridlines and y labels are derived from ticks" do
|
||||||
|
result = ChartView.line([entry("Jan", 100)], false)
|
||||||
|
assert length(result.grid) >= 2
|
||||||
|
assert Enum.all?(result.grid, &is_binary(&1.label))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "heatmap/1" do
|
||||||
|
test "builds columns from segment labels and a cell per column" do
|
||||||
|
series = [
|
||||||
|
entry("2024", 0, [seg("Jan", 2), seg("Feb", 4)]),
|
||||||
|
entry("2025", 0, [seg("Jan", 1), seg("Feb", 8)])
|
||||||
|
]
|
||||||
|
|
||||||
|
result = ChartView.heatmap(series)
|
||||||
|
assert result.columns == ["Jan", "Feb"]
|
||||||
|
assert result.column_count == 2
|
||||||
|
assert length(result.rows) == 2
|
||||||
|
assert Enum.all?(result.rows, &(length(&1.cells) == 2))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "zero-valued cells are transparent" do
|
||||||
|
series = [entry("2024", 0, [seg("Jan", 0), seg("Feb", 5)])]
|
||||||
|
[row] = ChartView.heatmap(series).rows
|
||||||
|
assert [%{value: 0, bg: "transparent", fg: "inherit"}, %{value: 5}] = row.cells
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores entries without segments" do
|
||||||
|
assert %{rows: [], columns: []} = ChartView.heatmap([entry("plain", 5)])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "stacked/1" do
|
||||||
|
test "computes segment widths against the largest stacked total" do
|
||||||
|
series = [
|
||||||
|
entry("2024", 0, [seg("draft", 1), seg("published", 1)]),
|
||||||
|
entry("2025", 0, [seg("draft", 2), seg("published", 2)])
|
||||||
|
]
|
||||||
|
|
||||||
|
result = ChartView.stacked(series)
|
||||||
|
assert result.max_total == 4
|
||||||
|
assert result.legend == [%{label: "draft", color: ChartView.color(0)}, %{label: "published", color: ChartView.color(1)}]
|
||||||
|
|
||||||
|
first_row = List.first(result.rows)
|
||||||
|
assert first_row.total == 2
|
||||||
|
assert Enum.map(first_row.segments, & &1.width) == ["25", "25"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "segment colours are keyed by label, consistent across rows" do
|
||||||
|
series = [
|
||||||
|
entry("a", 0, [seg("x", 1), seg("y", 1)]),
|
||||||
|
entry("b", 0, [seg("y", 1)])
|
||||||
|
]
|
||||||
|
|
||||||
|
result = ChartView.stacked(series)
|
||||||
|
[row_a, row_b] = result.rows
|
||||||
|
y_in_a = Enum.find(row_a.segments, &(&1.label == "y"))
|
||||||
|
y_in_b = Enum.find(row_b.segments, &(&1.label == "y"))
|
||||||
|
assert y_in_a.color == y_in_b.color
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user