482 lines
18 KiB
Plaintext
482 lines
18 KiB
Plaintext
@page "/admin/{TableDisplayName}"
|
|
@layout HopFrameLayout
|
|
@rendermode InteractiveServer
|
|
@implements IDisposable
|
|
|
|
@using HopFrame.Core.Config
|
|
@using HopFrame.Core.Callbacks
|
|
@using HopFrame.Core.Services
|
|
@using HopFrame.Web.Models
|
|
@using HopFrame.Web.Plugins
|
|
@using HopFrame.Web.Plugins.Events
|
|
@using HopFrame.Web.Services
|
|
@using Microsoft.JSInterop
|
|
|
|
@if (!DisplaySelection) {
|
|
<PageTitle>@_config?.DisplayName</PageTitle>
|
|
}
|
|
|
|
<FluentDialogProvider />
|
|
|
|
<div style="display: flex; flex-direction: column; height: 100%">
|
|
<FluentToolbar Class="hopframe-toolbar">
|
|
<h3>@_config?.DisplayName</h3>
|
|
@if (!DisplaySelection && _buttonToggles.ShowRefreshButton) {
|
|
<FluentButton
|
|
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
|
|
OnClick="Reload"
|
|
Loading="_loading"
|
|
Style="margin-left: 10px">
|
|
Refresh
|
|
</FluentButton>
|
|
}
|
|
|
|
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopLeft)) {
|
|
<FluentButton
|
|
IconStart="@(button.Icon?.GetInstance())"
|
|
OnClick="() => button.Handler.Invoke(null!, _config!)">
|
|
@button.Title
|
|
</FluentButton>
|
|
}
|
|
|
|
<FluentSpacer />
|
|
<div
|
|
style="position: relative; height: 32px"
|
|
class="hopframe-search">
|
|
|
|
<FluentSearch
|
|
@ref="_searchBox"
|
|
@oninput="OnSearch"
|
|
@onchange="OnSearch"
|
|
@onfocusin="() => { SearchFocus(); UpdateSearchSuggestions(); }"
|
|
@onfocusout="SearchUnfocus"
|
|
Style="width: 500px"/>
|
|
|
|
@if (_isSearchActive && _searchSuggestions.Count > 0) {
|
|
<FluentListbox
|
|
TOption="string"
|
|
Items="_searchSuggestions"
|
|
SelectedOptionChanged="SearchSuggestionSelected"
|
|
@onfocusin="SearchFocus"
|
|
@onfocusout="SearchUnfocus"/>
|
|
}
|
|
</div>
|
|
|
|
@if (_hasCreatePolicy && DisplayActions && _buttonToggles.ShowAddEntityButton) {
|
|
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entity</FluentButton>
|
|
}
|
|
|
|
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopRight)) {
|
|
<FluentButton
|
|
IconStart="@(button.Icon?.GetInstance())"
|
|
OnClick="() => button.Handler.Invoke(null!, _config!)">
|
|
@button.Title
|
|
</FluentButton>
|
|
}
|
|
</FluentToolbar>
|
|
<FluentProgress Visible="_loading" Width="100%" />
|
|
|
|
<div style="display: flex; overflow-y: auto; flex-grow: 1">
|
|
<div style="flex-grow: 1">
|
|
<FluentDataGrid Items="CurrentlyDisplayedModels.AsQueryable()">
|
|
@if (DisplaySelection) {
|
|
<SelectColumn
|
|
TGridItem="object"
|
|
SelectMode="SelectionMode"
|
|
SelectFromEntireRow="true"
|
|
OnSelect="data => SelectItem(data.Item, data.Selected)"
|
|
SelectAllDisabled="true"
|
|
Property="o => DialogData!.SelectedObjects.Contains(o)"
|
|
Style="min-width: max-content; height: 44px; display: grid; align-items: center" />
|
|
}
|
|
|
|
@foreach (var property in _config!.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order)) {
|
|
<PropertyColumn
|
|
Title="@property.Name" Property="o => DisplayProperty(property, o).Result"
|
|
Style="min-width: max-content; height: 44px;"
|
|
Sortable="@property.Sortable"/>
|
|
}
|
|
|
|
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy) && (_buttonToggles.ShowEditButton || _buttonToggles.ShowDeleteButton && _pluginButtons.Any(pb => pb.IsForTable(_config)))) {
|
|
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
|
|
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.OnEntry)) {
|
|
<FluentButton OnClick="() => button.Handler.Invoke(context, _config!)">
|
|
<FluentIcon Value="@(button.Icon!.GetInstance())" />
|
|
</FluentButton>
|
|
}
|
|
|
|
@if (_hasUpdatePolicy && _buttonToggles.ShowEditButton) {
|
|
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(context); }">
|
|
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
|
|
</FluentButton>
|
|
}
|
|
|
|
@if (_hasDeletePolicy && _buttonToggles.ShowDeleteButton) {
|
|
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(context); }">
|
|
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
|
|
</FluentButton>
|
|
}
|
|
</TemplateColumn>
|
|
}
|
|
</FluentDataGrid>
|
|
</div>
|
|
</div>
|
|
|
|
@if (_totalPages > 1) {
|
|
<div class="hopframe-paginator">
|
|
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage - 1)">
|
|
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
|
|
</FluentButton>
|
|
|
|
<span>Page</span>
|
|
|
|
<FluentSelect TOption="int"
|
|
Items="Enumerable.Range(0, _totalPages)"
|
|
OptionValue="@(p => p.ToString())"
|
|
OptionText="@(p => (p + 1).ToString())"
|
|
ValueChanged="async s => await ChangePage(Convert.ToInt32(s))"
|
|
Width="max-content" SelectedOption="@_currentPage"/>
|
|
|
|
<span>of @_totalPages</span>
|
|
|
|
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage + 1)">
|
|
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
|
|
</FluentButton>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<script>
|
|
function removeBg() {
|
|
const elements = document.querySelectorAll(".col-sort-button");
|
|
const style = new CSSStyleSheet();
|
|
style.replaceSync(".control { background: none !important; }");
|
|
elements.forEach(e => e?.shadowRoot?.adoptedStyleSheets?.push(style));
|
|
}
|
|
|
|
removeBg();
|
|
|
|
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
|
|
const arrayBuffer = await contentStreamReference.arrayBuffer();
|
|
const blob = new Blob([arrayBuffer]);
|
|
const url = URL.createObjectURL(blob);
|
|
const anchorElement = document.createElement('a');
|
|
anchorElement.href = url;
|
|
anchorElement.download = fileName ?? '';
|
|
anchorElement.click();
|
|
anchorElement.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
window.triggerClick = (elt) => elt.click();
|
|
</script>
|
|
|
|
<FluentToastProvider MaxToastCount="10" />
|
|
|
|
<InputFile style="display: none" @ref="FileInputElement" OnChange="OnInputFiles"></InputFile>
|
|
|
|
@inject IContextExplorer Explorer
|
|
@inject NavigationManager Navigator
|
|
@inject IJSRuntime Js
|
|
@inject IDialogService Dialogs
|
|
@inject IHopFrameAuthHandler Handler
|
|
@inject ICallbackEmitter Emitter
|
|
@inject IPluginOrchestrator PluginOrchestrator
|
|
@inject ISearchSuggestionProvider SearchSuggestions
|
|
|
|
@code {
|
|
|
|
[Parameter]
|
|
public required string TableDisplayName { get; set; }
|
|
|
|
[Parameter]
|
|
public bool DisplaySelection { get; set; }
|
|
|
|
[Parameter]
|
|
public bool DisplayActions { get; set; } = true;
|
|
|
|
[Parameter]
|
|
public RelationPickerDialogData? DialogData { get; set; }
|
|
|
|
[Parameter]
|
|
public DataGridSelectMode SelectionMode { get; set; } = DataGridSelectMode.Single;
|
|
|
|
[Parameter]
|
|
public int PerPage { get; set; } = 20;
|
|
|
|
private TableConfig? _config;
|
|
private ITableManager? _manager;
|
|
|
|
public object[] CurrentlyDisplayedModels = [];
|
|
private int _currentPage;
|
|
private int _totalPages;
|
|
private string? _searchTerm;
|
|
private bool _loading;
|
|
private bool _isSearchActive;
|
|
private IList<string> _searchSuggestions = [];
|
|
private FluentSearch? _searchBox;
|
|
|
|
private bool _hasUpdatePolicy;
|
|
private bool _hasDeletePolicy;
|
|
private bool _hasCreatePolicy;
|
|
|
|
private bool _allSelected;
|
|
|
|
private readonly CancellationTokenSource _tokenSource = new();
|
|
private List<PluginButton> _pluginButtons = new();
|
|
private DefaultButtonToggles _buttonToggles = new();
|
|
|
|
private string? _currentUser;
|
|
|
|
protected override void OnInitialized() {
|
|
_config ??= Explorer.GetTable(TableDisplayName);
|
|
|
|
if (_config is null || (_config.Ignored && DialogData is null)) {
|
|
Navigator.NavigateTo("/admin", true);
|
|
}
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync() {
|
|
if (!await Handler.IsAuthenticatedAsync(_config?.ViewPolicy)) {
|
|
Navigator.NavigateTo("/admin", true);
|
|
return;
|
|
}
|
|
|
|
_currentUser = await Handler.GetCurrentUserDisplayNameAsync();
|
|
|
|
var eventResult = await PluginOrchestrator.DispatchEvent(new TableInitializedEvent(this, _currentUser!) {
|
|
Table = _config!
|
|
});
|
|
if (eventResult.IsCanceled) return;
|
|
_pluginButtons = eventResult.PluginButtons;
|
|
_buttonToggles = eventResult.DefaultButtons;
|
|
|
|
_hasUpdatePolicy = await Handler.IsAuthenticatedAsync(_config?.UpdatePolicy);
|
|
_hasDeletePolicy = await Handler.IsAuthenticatedAsync(_config?.DeletePolicy);
|
|
_hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy);
|
|
|
|
_manager ??= Explorer.GetTableManager(_config!.PropertyName);
|
|
CurrentlyDisplayedModels = (await _manager!.LoadPage(_currentPage, PerPage)).ToArray();
|
|
_totalPages = await _manager.TotalPages(PerPage);
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
|
try {
|
|
await Js.InvokeVoidAsync("removeBg");
|
|
}
|
|
catch (Exception) {
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
public void Dispose() {
|
|
_searchCancel.Dispose();
|
|
_tokenSource.Dispose();
|
|
}
|
|
|
|
private CancellationTokenSource _searchCancel = new();
|
|
private async Task OnSearch(ChangeEventArgs eventArgs) {
|
|
await _searchCancel.CancelAsync();
|
|
_searchTerm = eventArgs.Value?.ToString();
|
|
if (_searchTerm is null) return;
|
|
_searchCancel = new();
|
|
UpdateSearchSuggestions();
|
|
|
|
await Task.Delay(500, _searchCancel.Token);
|
|
|
|
var eventResult = await PluginOrchestrator.DispatchEvent(new SearchEvent(this, _currentUser!) {
|
|
SearchTerm = _searchTerm,
|
|
Table = _config!,
|
|
CurrentPage = _currentPage
|
|
}, _tokenSource.Token);
|
|
if (eventResult.IsCanceled) {
|
|
if (eventResult.SearchResult is null) return;
|
|
|
|
CurrentlyDisplayedModels = eventResult.SearchResult.ToArray();
|
|
_totalPages = eventResult.TotalPages;
|
|
return;
|
|
}
|
|
_searchTerm = eventResult.SearchTerm;
|
|
|
|
await Reload();
|
|
}
|
|
|
|
private async Task SearchSuggestionSelected(string? suggestion) {
|
|
if (string.IsNullOrWhiteSpace(suggestion)) return;
|
|
_searchTerm = SearchSuggestions.CompleteSearchSuggestion(_config!, _searchTerm ?? string.Empty, suggestion);
|
|
_searchBox!.Value = _searchTerm;
|
|
_searchBox.FocusAsync();
|
|
UpdateSearchSuggestions();
|
|
|
|
if (!suggestion.EndsWith('='))
|
|
await OnSearch(new() {
|
|
Value = _searchTerm
|
|
});
|
|
}
|
|
|
|
private void UpdateSearchSuggestions() {
|
|
if (_config is null || !_config.ShowSearchSuggestions) return;
|
|
_searchSuggestions = SearchSuggestions.GenerateSearchSuggestions(_config, _searchTerm ?? string.Empty).ToList();
|
|
}
|
|
|
|
private CancellationTokenSource _searchFocusCancel = new();
|
|
private async Task SearchFocus() {
|
|
_isSearchActive = true;
|
|
await _searchFocusCancel.CancelAsync();
|
|
_searchFocusCancel = new();
|
|
}
|
|
|
|
private async Task SearchUnfocus() {
|
|
await Task.Delay(10, _searchFocusCancel.Token);
|
|
_isSearchActive = false;
|
|
}
|
|
|
|
public async Task Reload() {
|
|
_loading = true;
|
|
|
|
var eventResult = await PluginOrchestrator.DispatchEvent(new ReloadEvent(this, _currentUser!) {
|
|
Table = _config!
|
|
}, _tokenSource.Token);
|
|
if (eventResult.IsCanceled) {
|
|
_loading = false;
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(_searchTerm)) {
|
|
(var query, _totalPages) = await _manager!.Search(_searchTerm, _currentPage, PerPage);
|
|
CurrentlyDisplayedModels = query.ToArray();
|
|
}
|
|
else {
|
|
await OnInitializedAsync();
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
public async Task ChangePage(int page) {
|
|
var eventResult = await PluginOrchestrator.DispatchEvent(new PageChangeEvent(this, _currentUser!) {
|
|
CurrentPage = _currentPage,
|
|
NewPage = page,
|
|
TotalPages = _totalPages,
|
|
Table = _config!
|
|
}, _tokenSource.Token);
|
|
if (eventResult.IsCanceled) return;
|
|
page = eventResult.NewPage;
|
|
|
|
if (page < 0 || page > _totalPages - 1) return;
|
|
_currentPage = page;
|
|
await Reload();
|
|
}
|
|
|
|
private async Task DeleteEntry(object element) {
|
|
if (!await Handler.IsAuthenticatedAsync(_config?.DeletePolicy)) {
|
|
Navigator.NavigateTo("/admin", true);
|
|
return;
|
|
}
|
|
|
|
var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
|
|
var result = await dialog.Result;
|
|
if (result.Cancelled) return;
|
|
|
|
var eventResult = await PluginOrchestrator.DispatchEvent(new DeleteEntryEvent(this, _currentUser!) {
|
|
Entity = element,
|
|
Table = _config!
|
|
}, _tokenSource.Token);
|
|
if (eventResult.IsCanceled) return;
|
|
|
|
await _manager!.DeleteItem(element);
|
|
await Emitter.DispatchCallback(CallbackTypes.DeleteEntry(_config!), element);
|
|
await Reload();
|
|
}
|
|
|
|
private async Task CreateOrEdit(object? element) {
|
|
if (!await Handler.IsAuthenticatedAsync(element is null ? _config?.CreatePolicy : _config?.UpdatePolicy)) {
|
|
Navigator.NavigateTo("/admin", true);
|
|
return;
|
|
}
|
|
|
|
var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new DialogParameters {
|
|
TrapFocus = false
|
|
});
|
|
var result = await panel.Result;
|
|
var data = result.Data as EditorDialogData;
|
|
|
|
if (result.Cancelled) return;
|
|
|
|
HopFrameTablePageEventArgs eventArgs;
|
|
if (element is null) {
|
|
eventArgs = new CreateEntryEvent(this, _currentUser!) {
|
|
Table = _config!,
|
|
Entity = data!.CurrentObject!
|
|
};
|
|
}
|
|
else {
|
|
eventArgs = new UpdateEntryEvent(this, _currentUser!) {
|
|
Table = _config!,
|
|
Entity = data!.CurrentObject!
|
|
};
|
|
}
|
|
|
|
var eventResult = await PluginOrchestrator.DispatchEvent(eventArgs, _tokenSource.Token);
|
|
if (eventResult.IsCanceled) return;
|
|
|
|
if (element is null) {
|
|
await _manager!.AddItem(data!.CurrentObject!);
|
|
await Emitter.DispatchCallback(CallbackTypes.CreateEntry(_config!), data.CurrentObject!);
|
|
}
|
|
else {
|
|
await _manager!.EditItem(data!.CurrentObject!);
|
|
await Emitter.DispatchCallback(CallbackTypes.UpdateEntry(_config!), data.CurrentObject!);
|
|
}
|
|
|
|
await Reload();
|
|
}
|
|
|
|
private void SelectItem(object item, bool selected) {
|
|
var eventResult = PluginOrchestrator.DispatchEvent(new SelectEntryEvent(this, _currentUser!) {
|
|
Entity = item,
|
|
Selected = selected,
|
|
Table = _config!
|
|
}, _tokenSource.Token).Result;
|
|
if (eventResult.IsCanceled) return;
|
|
selected = eventResult.Selected;
|
|
|
|
if (!selected)
|
|
DialogData!.SelectedObjects.Remove(item);
|
|
else DialogData!.SelectedObjects.Add(item);
|
|
}
|
|
|
|
private void SelectAll() {
|
|
var selected = CurrentlyDisplayedModels.All(DialogData!.SelectedObjects.Contains);
|
|
foreach (var displayedModel in CurrentlyDisplayedModels) {
|
|
SelectItem(displayedModel, !selected);
|
|
}
|
|
_allSelected = selected;
|
|
}
|
|
|
|
private async Task<string> DisplayProperty(PropertyConfig config, object entry) {
|
|
var display = await _manager!.DisplayProperty(entry, config);
|
|
|
|
if (display.Length > config.DisplayLength)
|
|
display = display[..config.DisplayLength] + "...";
|
|
|
|
return display;
|
|
}
|
|
|
|
public InputFile? FileInputElement;
|
|
public Func<IEnumerable<IBrowserFile>, Task>? OnFileUpload;
|
|
private async Task OnInputFiles(InputFileChangeEventArgs e) {
|
|
if (OnFileUpload is null) return;
|
|
|
|
if (e.FileCount == 1) {
|
|
await OnFileUpload.Invoke([e.File]);
|
|
}
|
|
else {
|
|
await OnFileUpload.Invoke(e.GetMultipleFiles());
|
|
}
|
|
}
|
|
|
|
public void RequestRender() {
|
|
StateHasChanged();
|
|
}
|
|
|
|
} |