From 9c6d7329a600f489295c02b99122625ba8888976 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 1 Sep 2023 20:33:32 +0200 Subject: [PATCH] Workshop Library - Added library pages UI - Tweaked design to more closely match WinUI 3 gallery examples --- src/Artemis.UI.Shared/Styles/Border.axaml | 22 +-- src/Artemis.UI/Assets/Animations/empty.json | 1 + .../IActivatableViewModelExtensions.cs | 15 ++ src/Artemis.UI/Routing/Routes.cs | 15 +- .../Screens/Sidebar/SidebarViewModel.cs | 1 + .../Entries/EntryListBaseViewModel.cs | 148 ++++++++++++++++ ...ListView.axaml => EntryListItemView.axaml} | 10 +- ...ew.axaml.cs => EntryListItemView.axaml.cs} | 4 +- ...ViewModel.cs => EntryListItemViewModel.cs} | 4 +- .../Workshop/Home/WorkshopHomeViewModel.cs | 8 +- .../Workshop/Home/WorkshopOfflineViewModel.cs | 4 +- .../Workshop/Layout/LayoutListView.axaml | 41 +++-- .../Workshop/Layout/LayoutListViewModel.cs | 48 ++--- .../Library/Tabs/LibrarySubmissionsView.axaml | 68 ++++++- .../Tabs/LibrarySubmissionsViewModel.cs | 104 ++++++++++- .../Library/WorkshopLibraryVIew.axaml | 25 ++- .../Library/WorkshopLibraryVIew.axaml.cs | 25 +-- .../Workshop/Profile/ProfileDetailsView.axaml | 3 +- .../Workshop/Profile/ProfileListView.axaml | 16 +- .../Workshop/Profile/ProfileListViewModel.cs | 167 +++--------------- .../Artemis.WebClient.Workshop.csproj | 3 + .../Queries/GetSubmittedEntries.graphql | 10 ++ .../Services/IWorkshopService.cs | 46 ++--- .../graphql.config.yml | 2 +- src/Artemis.WebClient.Workshop/schema.graphql | 17 ++ 25 files changed, 519 insertions(+), 288 deletions(-) create mode 100644 src/Artemis.UI/Assets/Animations/empty.json create mode 100644 src/Artemis.UI/Extensions/IActivatableViewModelExtensions.cs create mode 100644 src/Artemis.UI/Screens/Workshop/Entries/EntryListBaseViewModel.cs rename src/Artemis.UI/Screens/Workshop/Entries/{EntryListView.axaml => EntryListItemView.axaml} (94%) rename src/Artemis.UI/Screens/Workshop/Entries/{EntryListView.axaml.cs => EntryListItemView.axaml.cs} (51%) rename src/Artemis.UI/Screens/Workshop/Entries/{EntryListViewModel.cs => EntryListItemViewModel.cs} (89%) create mode 100644 src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql diff --git a/src/Artemis.UI.Shared/Styles/Border.axaml b/src/Artemis.UI.Shared/Styles/Border.axaml index d753809a0..e3997e5ec 100644 --- a/src/Artemis.UI.Shared/Styles/Border.axaml +++ b/src/Artemis.UI.Shared/Styles/Border.axaml @@ -23,15 +23,11 @@ - - - 8 - - + + + + + + + You are not logged in + + In order to manage your submissions you must be logged in. + + + + + + + + Oh boy, it's empty here 🤔 + + Any entries you submit to the workshop you can later manage here + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs index cbb98d615..3421b6aff 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/LibrarySubmissionsViewModel.cs @@ -1,8 +1,108 @@ +using System; +using System.Collections.ObjectModel; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Extensions; +using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Screens.Workshop.SubmissionWizard; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using DynamicData; +using ReactiveUI; +using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Library.Tabs; -public class LibrarySubmissionsViewModel : ActivatableViewModelBase +public class LibrarySubmissionsViewModel : ActivatableViewModelBase, IWorkshopViewModel { - + private readonly IWorkshopClient _client; + private readonly SourceCache _entries; + private readonly IWindowService _windowService; + private bool _isLoading = true; + private bool _workshopReachable; + + public LibrarySubmissionsViewModel(IWorkshopClient client, IAuthenticationService authenticationService, IWindowService windowService, IWorkshopService workshopService, IRouter router) + { + _client = client; + _windowService = windowService; + _entries = new SourceCache(e => e.Id); + _entries.Connect().Bind(out ReadOnlyObservableCollection entries).Subscribe(); + + AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission, this.WhenAnyValue(vm => vm.WorkshopReachable)); + Login = ReactiveCommand.CreateFromTask(ExecuteLogin, this.WhenAnyValue(vm => vm.WorkshopReachable)); + NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); + + IsLoggedIn = authenticationService.IsLoggedIn; + Entries = entries; + + this.WhenActivatedAsync(async d => + { + WorkshopReachable = await workshopService.ValidateWorkshopStatus(d.AsCancellationToken()); + if (WorkshopReachable) + await GetEntries(d.AsCancellationToken()); + }); + } + + public ReactiveCommand Login { get; } + public ReactiveCommand AddSubmission { get; } + public ReactiveCommand NavigateToEntry { get; } + + public IObservable IsLoggedIn { get; } + public ReadOnlyObservableCollection Entries { get; } + + public bool WorkshopReachable + { + get => _workshopReachable; + set => RaiseAndSetIfChanged(ref _workshopReachable, value); + } + + public bool IsLoading + { + get => _isLoading; + set => RaiseAndSetIfChanged(ref _isLoading, value); + } + + private async Task ExecuteLogin(CancellationToken ct) + { + await _windowService.CreateContentDialog().WithViewModel(out WorkshopLoginViewModel _).WithTitle("Workshop login").ShowAsync(); + } + + private async Task ExecuteAddSubmission(CancellationToken arg) + { + await _windowService.ShowDialogAsync(); + } + + private Task ExecuteNavigateToEntry(IGetSubmittedEntries_SubmittedEntries entry, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task GetEntries(CancellationToken ct) + { + IsLoading = true; + + try + { + IOperationResult result = await _client.GetSubmittedEntries.ExecuteAsync(null, ct); + + if (result.Data?.SubmittedEntries == null) + _entries.Clear(); + else + _entries.Edit(e => + { + e.Clear(); + e.AddOrUpdate(result.Data.SubmittedEntries); + }); + } + finally + { + IsLoading = false; + } + } + + public EntryType? EntryType => null; } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml index f6e2f5740..1dafa387c 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml @@ -2,26 +2,25 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:library="clr-namespace:Artemis.UI.Screens.Workshop.Library" - xmlns:routing="clr-namespace:Artemis.UI.Routing" - xmlns:ui1="clr-namespace:Artemis.UI" + xmlns:ui="clr-namespace:Artemis.UI" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.Library.WorkshopLibraryView" x:DataType="library:WorkshopLibraryViewModel"> - - + + - - + - - - - - - + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs index f2faa7e5a..5057296ac 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/WorkshopLibraryVIew.axaml.cs @@ -21,17 +21,18 @@ public partial class WorkshopLibraryView : ReactiveUserControl - { - if (ViewModel == null) - return; - - SlideNavigationTransitionInfo transitionInfo = new() - { - Effect = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab) > _lastIndex ? SlideNavigationTransitionEffect.FromRight : SlideNavigationTransitionEffect.FromLeft - }; - TabFrame.NavigateFromObject(viewModel, new FrameNavigationOptions {TransitionInfoOverride = transitionInfo}); - _lastIndex = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab); - }); + Dispatcher.UIThread.Invoke(() => TabFrame.NavigateFromObject(viewModel, new FrameNavigationOptions {TransitionInfoOverride = GetTransitionInfo()})); + } + + private SlideNavigationTransitionInfo GetTransitionInfo() + { + if (ViewModel?.SelectedTab == null) + return new SlideNavigationTransitionInfo(); + + SlideNavigationTransitionEffect effect = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab) > _lastIndex ? SlideNavigationTransitionEffect.FromRight : SlideNavigationTransitionEffect.FromLeft; + SlideNavigationTransitionInfo info = new() {Effect = effect}; + _lastIndex = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab); + + return info; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml index 00ffc76c9..f41c65768 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml @@ -23,8 +23,7 @@ - - - Categories - - - + + + Categories + + + - - + + + diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs index 23ddddfa2..521c9a351 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -1,165 +1,44 @@ using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Categories; using Artemis.UI.Screens.Workshop.Entries; -using Artemis.UI.Screens.Workshop.Parameters; -using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Workshop; -using Avalonia.Threading; -using DryIoc.ImTools; -using DynamicData; -using ReactiveUI; -using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Profile; -public class ProfileListViewModel : RoutableScreen, IWorkshopViewModel +public class ProfileListViewModel : EntryListBaseViewModel { - private readonly INotificationService _notificationService; - private readonly Func _getEntryListViewModel; - private readonly IWorkshopClient _workshopClient; - private readonly ObservableAsPropertyHelper _showPagination; - private readonly ObservableAsPropertyHelper _isLoading; - private SourceList _entries = new(); - private int _page; - private int _loadedPage = -1; - private int _totalPages = 1; - private int _entriesPerPage = 10; - + /// public ProfileListViewModel(IWorkshopClient workshopClient, - IRouter router, - CategoriesViewModel categoriesViewModel, + IRouter router, + CategoriesViewModel categoriesViewModel, INotificationService notificationService, - Func getEntryListViewModel) + Func getEntryListViewModel) + : base(workshopClient, router, categoriesViewModel, notificationService, getEntryListViewModel) { - _workshopClient = workshopClient; - _notificationService = notificationService; - _getEntryListViewModel = getEntryListViewModel; - _showPagination = this.WhenAnyValue(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination); - _isLoading = this.WhenAnyValue(vm => vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading); + } - CategoriesViewModel = categoriesViewModel; - - _entries.Connect() - .ObserveOn(new AvaloniaSynchronizationContext(DispatcherPriority.SystemIdle)) - .Transform(getEntryListViewModel) - .Bind(out ReadOnlyObservableCollection entries) - .Subscribe(); - Entries = entries; - - // Respond to page changes - this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"workshop/profiles/{p}"))); - - // Respond to filter changes - this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => + #region Overrides of EntryListBaseViewModel + + /// + protected override string GetPagePath(int page) + { + return $"workshop/profiles/{page}"; + } + + /// + protected override EntryFilterInput GetFilter() + { + return new EntryFilterInput { - // Reset to page one, will trigger a query - if (Page != 1) - Page = 1; - // If already at page one, force a query - else - Task.Run(() => Query(CancellationToken.None)); - }).DisposeWith(d)); - } - - public bool ShowPagination => _showPagination.Value; - public bool IsLoading => _isLoading.Value; - - public CategoriesViewModel CategoriesViewModel { get; } - - public ReadOnlyObservableCollection Entries { get; } - - public int Page - { - get => _page; - set => RaiseAndSetIfChanged(ref _page, value); - } - - public int LoadedPage - { - get => _loadedPage; - set => RaiseAndSetIfChanged(ref _loadedPage, value); - } - - public int TotalPages - { - get => _totalPages; - set => RaiseAndSetIfChanged(ref _totalPages, value); - } - - public int EntriesPerPage - { - get => _entriesPerPage; - set => RaiseAndSetIfChanged(ref _entriesPerPage, value); - } - - public override async Task OnNavigating(WorkshopListParameters parameters, NavigationArguments args, CancellationToken cancellationToken) - { - Page = Math.Max(1, parameters.Page); - - // Throttle page changes, wait longer for the first one to keep UI smooth - // if (Entries == null) - // await Task.Delay(400, cancellationToken); - // else - await Task.Delay(200, cancellationToken); - - if (!cancellationToken.IsCancellationRequested) - await Query(cancellationToken); - } - - private async Task Query(CancellationToken cancellationToken) - { - try - { - EntryFilterInput filter = GetFilter(); - IOperationResult entries = await _workshopClient.GetEntries.ExecuteAsync(filter, EntriesPerPage * (Page - 1), EntriesPerPage, cancellationToken); - entries.EnsureNoErrors(); - - if (entries.Data?.Entries?.Items != null) + And = new[] { - TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); - _entries.Edit(e => - { - e.Clear(); - e.AddRange(entries.Data.Entries.Items); - }); + new EntryFilterInput {EntryType = new EntryTypeOperationFilterInput {Eq = WebClient.Workshop.EntryType.Profile}}, + base.GetFilter() } - else - TotalPages = 1; - } - catch (Exception e) - { - _notificationService.CreateNotification() - .WithTitle("Failed to load entries") - .WithMessage(e.Message) - .WithSeverity(NotificationSeverity.Error) - .Show(); - } - finally - { - LoadedPage = Page; - } - } - - private EntryFilterInput GetFilter() - { - EntryFilterInput filter = new() - { - EntryType = new EntryTypeOperationFilterInput {Eq = WebClient.Workshop.EntryType.Profile}, - And = CategoriesViewModel.CategoryFilters }; - - return filter; } - public EntryType? EntryType => null; + #endregion } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index d1c6d5490..d2838936b 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -35,5 +35,8 @@ MSBuild:GenerateGraphQLCode + + MSBuild:GenerateGraphQLCode + diff --git a/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql new file mode 100644 index 000000000..422fcaf90 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntries.graphql @@ -0,0 +1,10 @@ +query GetSubmittedEntries($filter: EntryFilterInput) { + submittedEntries(where: $filter order: {createdAt: DESC}) { + id + name + summary + entryType + downloads + createdAt + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs index c79bde1e1..6b18e7d12 100644 --- a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -1,30 +1,19 @@ -using System.Net; using System.Net.Http.Headers; using Artemis.UI.Shared.Routing; -using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop.UploadHandlers; -using Avalonia.Media.Imaging; -using Avalonia.Threading; namespace Artemis.WebClient.Workshop.Services; public class WorkshopService : IWorkshopService { - private readonly Dictionary _entryIconCache = new(); private readonly IHttpClientFactory _httpClientFactory; private readonly IRouter _router; - private readonly SemaphoreSlim _iconCacheLock = new(1); - public WorkshopService(IHttpClientFactory httpClientFactory, IMainWindowService mainWindowService, IRouter router) + public WorkshopService(IHttpClientFactory httpClientFactory, IRouter router) { _httpClientFactory = httpClientFactory; _router = router; - mainWindowService.MainWindowClosed += (_, _) => Dispatcher.UIThread.InvokeAsync(async () => - { - await Task.Delay(1000); - ClearCache(); - }); } public async Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken) @@ -48,52 +37,41 @@ public class WorkshopService : IWorkshopService } /// - public async Task GetWorkshopStatus() + public async Task GetWorkshopStatus(CancellationToken cancellationToken) { try { // Don't use the workshop client which adds auth headers HttpClient client = _httpClientFactory.CreateClient(); - HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, WorkshopConstants.WORKSHOP_URL + "/status")); + HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, WorkshopConstants.WORKSHOP_URL + "/status"), cancellationToken); return new IWorkshopService.WorkshopStatus(response.IsSuccessStatusCode, response.StatusCode.ToString()); } + catch (OperationCanceledException e) + { + return new IWorkshopService.WorkshopStatus(false, e.Message); + } catch (HttpRequestException e) { return new IWorkshopService.WorkshopStatus(false, e.Message); } } + /// /// - public async Task ValidateWorkshopStatus() + public async Task ValidateWorkshopStatus(CancellationToken cancellationToken) { - IWorkshopService.WorkshopStatus status = await GetWorkshopStatus(); + IWorkshopService.WorkshopStatus status = await GetWorkshopStatus(cancellationToken); if (!status.IsReachable) await _router.Navigate($"workshop/offline/{status.Message}"); return status.IsReachable; } - - private void ClearCache() - { - try - { - List values = _entryIconCache.Values.ToList(); - _entryIconCache.Clear(); - foreach (Stream bitmap in values) - bitmap.Dispose(); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } } public interface IWorkshopService { Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); - Task GetWorkshopStatus(); - Task ValidateWorkshopStatus(); + Task GetWorkshopStatus(CancellationToken cancellationToken); + Task ValidateWorkshopStatus(CancellationToken cancellationToken); public record WorkshopStatus(bool IsReachable, string Message); } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/graphql.config.yml b/src/Artemis.WebClient.Workshop/graphql.config.yml index 9662a514f..a8ba99703 100644 --- a/src/Artemis.WebClient.Workshop/graphql.config.yml +++ b/src/Artemis.WebClient.Workshop/graphql.config.yml @@ -2,7 +2,7 @@ schema: schema.graphql extensions: endpoints: Default GraphQL Endpoint: - url: https://workshop.artemis-rgb.com/graphql + url: https://localhost:7281/graphql headers: user-agent: JS GraphQL introspect: true diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql index 8b99d85ea..bed72a4a2 100644 --- a/src/Artemis.WebClient.Workshop/schema.graphql +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -41,6 +41,7 @@ type Entry { id: UUID! images: [Image!]! latestRelease: Release + latestReleaseId: UUID name: String! releases: [Release!]! summary: String! @@ -62,6 +63,7 @@ type Query { entries(order: [EntrySortInput!], skip: Int, take: Int, where: EntryFilterInput): EntriesCollectionSegment entry(id: UUID!): Entry searchEntries(input: String!, order: [EntrySortInput!], type: EntryType, where: EntryFilterInput): [Entry!]! + submittedEntries(order: [EntrySortInput!], where: EntryFilterInput): [Entry!]! } type Release { @@ -156,6 +158,8 @@ input EntryFilterInput { iconId: UuidOperationFilterInput id: UuidOperationFilterInput images: ListFilterInputTypeOfImageFilterInput + latestRelease: ReleaseFilterInput + latestReleaseId: UuidOperationFilterInput name: StringOperationFilterInput or: [EntryFilterInput!] releases: ListFilterInputTypeOfReleaseFilterInput @@ -173,6 +177,8 @@ input EntrySortInput { icon: ImageSortInput iconId: SortEnumType id: SortEnumType + latestRelease: ReleaseSortInput + latestReleaseId: SortEnumType name: SortEnumType summary: SortEnumType } @@ -267,6 +273,17 @@ input ReleaseFilterInput { version: StringOperationFilterInput } +input ReleaseSortInput { + createdAt: SortEnumType + downloadSize: SortEnumType + downloads: SortEnumType + entry: EntrySortInput + entryId: SortEnumType + id: SortEnumType + md5Hash: SortEnumType + version: SortEnumType +} + input StringOperationFilterInput { and: [StringOperationFilterInput!] contains: String