diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7f0b17331..c4d4ea566 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,6 +5,11 @@ on: branches: [ "develop", "main" ] pull_request: branches: [ "develop", "main" ] + paths: + - "**/*.cs" + - "**/*.csproj" + - "**/*.ts" + - "**/*.js" schedule: - cron: '34 21 * * 2' diff --git a/.github/workflows/develop-api.yaml b/.github/workflows/develop-api.yaml index 6b643c7ae..f499f0fef 100644 --- a/.github/workflows/develop-api.yaml +++ b/.github/workflows/develop-api.yaml @@ -51,15 +51,24 @@ jobs: k8s-environment: develop deploy-domain: lexbox.dev.languagetechnology.org - integration-tests: - name: Integration tests - concurrency: develop - uses: ./.github/workflows/integration-test.yaml - permissions: - checks: write - secrets: inherit - needs: deploy-api + integration-test-gha: + name: Self hosted integration tests + needs: [build-api, set-version] + uses: ./.github/workflows/integration-test-gha.yaml with: - environment: develop - runs-on: self-hosted - hg-version: 6 + lexbox-api-tag: ${{ needs.set-version.outputs.version }} + + + # for now disabling integration tests on self hosted since they're flaky, depend on tests in gha above +# integration-tests: +# name: Integration tests +# concurrency: develop +# uses: ./.github/workflows/integration-test.yaml +# permissions: +# checks: write +# secrets: inherit +# needs: deploy-api +# with: +# environment: develop +# runs-on: self-hosted +# hg-version: 6 diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 324ed518c..6b67a6782 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -23,8 +23,6 @@ jobs: name: Build FW Lite and run tests timeout-minutes: 20 runs-on: windows-latest - env: - NuGetPackageSourceCredentials_github: ${{ secrets.GH_NUGET_PACKAGE_CREDS }} steps: - name: Checkout uses: actions/checkout@v4 @@ -40,7 +38,6 @@ jobs: - name: Dotnet build working-directory: backend/FwLite/FwLiteDesktop run: | - dotnet nuget enable source github dotnet build --configuration Release - name: Dotnet test @@ -54,6 +51,50 @@ jobs: pnpm install pnpm run build-app + publish-mac: + name: Publish FW Lite app for Mac + needs: build-and-test + timeout-minutes: 30 + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + - uses: actions/setup-node@v4 + with: + node-version-file: './frontend/package.json' + + - name: Build viewer + working-directory: frontend/viewer + run: | + corepack enable + pnpm install + pnpm run build-app + + - name: Dotnet build + working-directory: backend/FwLite/LocalWebApp + run: dotnet build --configuration Release + + - name: Publish OSX + working-directory: backend/FwLite/LocalWebApp + run: dotnet publish -r osx-x64 --artifacts-path ../artifacts + + - name: Publish OSX ARM + working-directory: backend/FwLite/LocalWebApp + run: dotnet publish -r osx-arm64 --artifacts-path ../artifacts + + - name: Upload local web app artifacts + uses: actions/upload-artifact@v4 + with: + name: fw-lite-local-web-app-mac + if-no-files-found: error + path: backend/FwLite/artifacts/publish/LocalWebApp/* + + publish-app: name: Publish FW Lite app @@ -95,14 +136,6 @@ jobs: working-directory: backend/FwLite/LocalWebApp run: dotnet publish -r linux-x64 --artifacts-path ../artifacts - - name: Publish OSX - working-directory: backend/FwLite/LocalWebApp - run: dotnet publish -r osx-x64 --artifacts-path ../artifacts - - - name: Publish OSX ARM - working-directory: backend/FwLite/LocalWebApp - run: dotnet publish -r osx-arm64 --artifacts-path ../artifacts - - name: Publish Windows working-directory: backend/FwLite/LocalWebApp run: dotnet publish -r win-x64 --artifacts-path ../artifacts diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml new file mode 100644 index 000000000..162b88ce6 --- /dev/null +++ b/.github/workflows/integration-test-gha.yaml @@ -0,0 +1,70 @@ +name: Self contained integration tests +on: + workflow_dispatch: + inputs: + lexbox-api-tag: + description: 'The version of lexbox-api to test' + default: 'develop' + required: true + workflow_call: + inputs: + lexbox-api-tag: + description: 'The version of lexbox-api to test' + default: 'develop' + type: string + required: true + +jobs: + execute: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Install Task + uses: arduino/setup-task@v2 + - run: task setup-local-env + - name: setup k8s + uses: helm/kind-action@v1.10.0 + with: + config: deployment/gha/kind.yaml + - name: Verify k8s + run: | + kubectl cluster-info + kubectl get nodes + - name: Update image lexbox-api version + uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2 + with: + cmd: yq eval -i '(.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-api").newTag) = "${{ inputs.lexbox-api-tag }}"' "./deployment/gha/kustomization.yaml" + - name: deploy + run: | + kubectl create namespace languagedepot + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.0/cert-manager.yaml + kubectl wait --for=condition=Ready --timeout=90s pod -l 'app in (cert-manager, webhook)' -n cert-manager + kubectl apply -k ./deployment/gha + kubectl wait --for=condition=Ready --timeout=120s pod -l 'app.kubernetes.io/component=controller' -n languagedepot + kubectl wait --for=condition=Ready --timeout=120s pod -l 'app in (lexbox, ui, hg, db)' -n languagedepot + - name: status + if: failure() + run: | + kubectl describe pods -l 'app in (lexbox, ui, hg, db)' -n languagedepot + echo "========== LOGS ==========" + kubectl logs -l 'app in (lexbox, ui, hg, db)' -n languagedepot --prefix --all-containers --tail=50 + echo "========== INGRESS ==========" + kubectl logs -l 'app.kubernetes.io/name=ingress-nginx' -n languagedepot --prefix --all-containers --tail=50 + - name: forward ingress + run: kubectl port-forward service/ingress-nginx-controller 6579:80 -n languagedepot & + - name: verify ingress + run: curl -v http://localhost:6579 + - name: build + run: dotnet restore LexBoxOnly.slnf && dotnet build --no-restore LexBoxOnly.slnf + - name: Dotnet test + env: + TEST_SERVER_HOSTNAME: 'localhost:6579' + TEST_STANDARD_HG_HOSTNAME: 'hg.localhost:6579' + TEST_RESUMABLE_HG_HOSTNAME: 'resumable.localhost:6579' + TEST_PROJECT_CODE: 'sena-3' + TEST_DEFAULT_PASSWORD: 'pass' + run: dotnet test LexBoxOnly.slnf --logger GitHubActions --filter "Category=Integration|Category=FlakyIntegration" --blame-hang-timeout 40m + diff --git a/README.md b/README.md index fd2b9666b..e19d5a8c0 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ The SvelteKit UI will be available at http://localhost:3000. * http://localhost:5158/api/graphql/ui - GraphQL UI * http://localhost:8088/hg - hg web UI (add the project code and use the url in FLEx to clone) * http://localhost:1080 - maildev UI +* http://localhost:4810 - pgadmin UI (username admin@test.com, password pass) * http://localhost:18888 - [aspire dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard) (OTEL traces) ### Seeded data diff --git a/Taskfile.yml b/Taskfile.yml index 668374679..487925efe 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -31,28 +31,32 @@ includes: tasks: setup: - deps: [ setup-win, setup-unix ] + deps: [ setup-win, setup-unix, setup-local-env ] cmds: - git config blame.ignoreRevsFile .git-blame-ignore-revs - - echo "HONEYCOMB_API_KEY=__REPLACE__" >> deployment/local-dev/local.env - - echo "#OTEL_SDK_DISABLED=true" >> deployment/local-dev/local.env - - echo "GOOGLE_OAUTH_CLIENT_ID=__REPLACE__.apps.googleusercontent.com" >> deployment/local-dev/local.env - - echo "GOOGLE_OAUTH_CLIENT_SECRET=__REPLACE__" >> deployment/local-dev/local.env - kubectl --context=docker-desktop apply -f deployment/setup/namespace.yaml - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.0/cert-manager.yaml - docker build -t local-dev-init data/ setup-win: platforms: [ windows ] cmds: + - powershell "if (!(test-path deployment/local-dev/local.env)) { cp deployment/local-dev/local.env.template deployment/local-dev/local.env }" - powershell -File download.ps1 sena-3 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS' 'BEC5131799DB07BF8D84D8FC1F3169FB2574F2A1F4C37F6898EAB563A4AE95B8' - powershell -File download.ps1 empty 'https://drive.google.com/uc?export=download&id=1p73u-AGdSwNkg_5KEv9-4iLRuN-1V-LD' 'F4EB48D2C7B3294DCA93965F14F058E56D797F38D562B86CF0372F774E1B486B' - powershell -File download.ps1 elawa 'https://drive.usercontent.google.com/download?export=download&id=1Jk-eSDho8ATBMS-Kmfatwi-MWQth26ro&confirm=t' "E3608F1E3188CE5FDB166FBF9D5AAD06558DB68EFA079FB453881572B50CB8E3" setup-unix: platforms: [ linux, darwin ] cmds: + - "test -f deployment/local-dev/local.env || cp deployment/local-dev/local.env.template deployment/local-dev/local.env" - wget -c -O {{.DATA_DIR}}/sena-3.zip 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS' - wget -c -O {{.DATA_DIR}}/empty.zip 'https://drive.google.com/uc?export=download&id=1p73u-AGdSwNkg_5KEv9-4iLRuN-1V-LD' - wget -c -O {{.DATA_DIR}}/elawa.zip 'https://drive.usercontent.google.com/download?export=download&id=1Jk-eSDho8ATBMS-Kmfatwi-MWQth26ro&confirm=t' + setup-local-env: + cmds: + - echo "HONEYCOMB_API_KEY=__REPLACE__" > deployment/local-dev/local.env + - echo "#OTEL_SDK_DISABLED=true" >> deployment/local-dev/local.env + - echo "GOOGLE_OAUTH_CLIENT_ID=__REPLACE__.apps.googleusercontent.com" >> deployment/local-dev/local.env + - echo "GOOGLE_OAUTH_CLIENT_SECRET=__REPLACE__" >> deployment/local-dev/local.env # k8s up: diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 4cb2468b5..7f17f9d48 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -98,7 +98,7 @@ public Task GetWritingSystems() .Select(ws => ws.Id).ToHashSet(); var writingSystems = new WritingSystems { - Vernacular = Cache.ServiceLocator.WritingSystems.VernacularWritingSystems.Select(ws => new WritingSystem + Vernacular = WritingSystemContainer.CurrentVernacularWritingSystems.Select(ws => new WritingSystem { //todo determine current and create a property for that. Id = ws.Id, @@ -107,7 +107,7 @@ public Task GetWritingSystems() Font = ws.DefaultFontName, Exemplars = ws.CharacterSets.FirstOrDefault(s => s.Type == "index")?.Characters.ToArray() ?? [] }).ToArray(), - Analysis = Cache.ServiceLocator.WritingSystems.AnalysisWritingSystems.Select(ws => new WritingSystem + Analysis = WritingSystemContainer.CurrentAnalysisWritingSystems.Select(ws => new WritingSystem { Id = ws.Id, Name = ws.LanguageTag, @@ -164,13 +164,13 @@ public async Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) public async IAsyncEnumerable GetSemanticDomains() { - foreach (var semanticDomain in SemanticDomainRepository.AllInstances().OrderBy(p => p.Name.BestAnalysisAlternative.Text)) + foreach (var semanticDomain in SemanticDomainRepository.AllInstances().OrderBy(p => p.Abbreviation.UiString)) { yield return new SemanticDomain { Id = semanticDomain.Guid, Name = FromLcmMultiString(semanticDomain.Name), - Code = semanticDomain.OcmCodes ?? "" + Code = semanticDomain.Abbreviation.UiString ?? "" }; } } diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs index 6789eddf9..4e958bdce 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs @@ -68,17 +68,32 @@ public override Guid? PartOfSpeechId { if (semanticDomain.Id != default) sense.SemanticDomainsRC.Remove(sense.SemanticDomainsRC.First(sd => sd.Guid == semanticDomain.Id)); }, - i => new UpdateProxySemanticDomain { Id = sense.SemanticDomainsRC.ElementAt(i).Guid }, + i => new UpdateProxySemanticDomain(sense.SemanticDomainsRC, sense.SemanticDomainsRC.ElementAt(i).Guid, lexboxLcmApi), sense.SemanticDomainsRC.Count ); set => throw new NotImplementedException(); } - public class UpdateProxySemanticDomain + public class UpdateProxySemanticDomain( + ILcmReferenceCollection senseSemanticDomainsRc, + Guid id, + FwDataMiniLcmApi lexboxLcmApi) { - public Guid Id { get; set; } + public Guid Id + { + get => id; + set + { + if (value == id) return; + if (value == default) throw new ArgumentException("Cannot set to default"); + senseSemanticDomainsRc.Remove(senseSemanticDomainsRc.First(sd => sd.Guid == id)); + senseSemanticDomainsRc.Add(lexboxLcmApi.GetLcmSemanticDomain(value)); + id = value; + } + } + public string? Code { get; set; } - public MultiString? Name { get; set; } + public MultiString? Name { get; set; } = new(); } public override IList ExampleSentences diff --git a/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs b/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs index a62439796..c944a1e21 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs @@ -7,6 +7,7 @@ public class FieldWorksProjectList { public static IEnumerable EnumerateProjects() { + if (!Directory.Exists(ProjectLoader.ProjectFolder)) Directory.CreateDirectory(ProjectLoader.ProjectFolder); foreach (var directory in Directory.EnumerateDirectories(ProjectLoader.ProjectFolder)) { var projectName = Path.GetFileName(directory); diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj index 191a51c07..606eb038f 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj @@ -11,6 +11,7 @@ + diff --git a/backend/FwLite/LcmCrdt/Changes/CreateSemanticDomainChange.cs b/backend/FwLite/LcmCrdt/Changes/CreateSemanticDomainChange.cs index 7ec0c21cb..682decbf0 100644 --- a/backend/FwLite/LcmCrdt/Changes/CreateSemanticDomainChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/CreateSemanticDomainChange.cs @@ -1,4 +1,5 @@ -using SIL.Harmony; +using System.Text.Json.Serialization; +using SIL.Harmony; using SIL.Harmony.Changes; using SIL.Harmony.Entities; using MiniLcm; @@ -6,8 +7,9 @@ namespace LcmCrdt.Changes; -public class CreateSemanticDomainChange(Guid semanticDomainId, MultiString name, string code, bool predefined = false) - : CreateChange(semanticDomainId), ISelfNamedType +// must use the name `entityId` to support json deserialization as it must match the name of the property +public class CreateSemanticDomainChange(Guid entityId, MultiString name, string code, bool predefined = false) + : CreateChange(entityId), ISelfNamedType { public MultiString Name { get; } = name; public bool Predefined { get; } = predefined; diff --git a/backend/FwLite/LocalWebApp/LocalWebApp.csproj b/backend/FwLite/LocalWebApp/LocalWebApp.csproj index f0a0b5f7f..e2efe22e6 100644 --- a/backend/FwLite/LocalWebApp/LocalWebApp.csproj +++ b/backend/FwLite/LocalWebApp/LocalWebApp.csproj @@ -47,4 +47,24 @@ + + + + PreserveNewest + + + + %(Filename)%(Extension) + PreserveNewest + + diff --git a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs index 56dd1b5ed..4d9a83e67 100644 --- a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs +++ b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs @@ -52,8 +52,16 @@ public static WebApplication SetupAppServer(string[] args, Action { - return dbcontext.Commits.DefaultOrder().Take(10).Select(c => new Activity(c.Id, c.HybridDateTime.DateTime, ChangeName(c.ChangeEntities), c.ChangeEntities)).AsAsyncEnumerable(); + return dbcontext.Commits + .DefaultOrderDescending() + .Take(20) + .Select(c => new Activity(c.Id, c.HybridDateTime.DateTime, c.ChangeEntities)) + .AsAsyncEnumerable(); }); return group; } @@ -35,10 +42,20 @@ private static string ChangeName(List> changeEntities) return changeEntities switch { { Count: 0 } => "No changes", - { Count: 1 } => changeEntities[0].Change.GetType().Name, - _ => "Multiple changes" + { Count: 1 } => changeEntities[0].Change switch + { + IChange change when change.GetType().Name.StartsWith("JsonPatchChange") => "Change " + change.EntityType.Name, + IChange change => change.GetType().Name.Humanize() + }, + { Count: var count } => $"{count} changes" }; } - public record Activity(Guid CommitId, DateTimeOffset Timestamp, string ChangeName, List> Changes); + public record Activity( + Guid CommitId, + DateTimeOffset Timestamp, + List> Changes) + { + public string ChangeName => ChangeName(Changes); + } } diff --git a/backend/LexData/DbStartupService.cs b/backend/LexData/DbStartupService.cs index 316ded684..9f6108362 100644 --- a/backend/LexData/DbStartupService.cs +++ b/backend/LexData/DbStartupService.cs @@ -1,9 +1,11 @@ using System.Diagnostics; +using System.Net.Sockets; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Logging; +using Npgsql; namespace LexData; @@ -43,7 +45,18 @@ public async Task StartAsync(CancellationToken cancellationToken) var startTime = Stopwatch.GetTimestamp(); await using var serviceScope = _serviceProvider.CreateAsyncScope(); var dbContext = serviceScope.ServiceProvider.GetRequiredService(); - await dbContext.Database.MigrateAsync(cancellationToken); + var timeoutToken = CancellationTokenSource + .CreateLinkedTokenSource(cancellationToken, new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token) + .Token; + int counter = 1; + while (!await TryMigrate(dbContext, timeoutToken)) + { + timeoutToken.ThrowIfCancellationRequested(); + _logger.LogInformation("Waiting for database connection... {Counter}", counter); + counter++; + await Task.Delay(TimeSpan.FromSeconds(counter), cancellationToken); + } + var environment = serviceScope.ServiceProvider.GetRequiredService(); var seedingData = serviceScope.ServiceProvider.GetRequiredService(); if (environment.IsDevelopment() || environment.IsStaging()) @@ -60,6 +73,31 @@ public async Task StartAsync(CancellationToken cancellationToken) _logger.LogInformation($"Migrations applied successfully ({elapsedTime:s\\.fff} sec)"); } + private async Task TryMigrate(DbContext dbContext, CancellationToken cancellationToken) + { + try + { + // there's a method to check if we can connect, but that won't work when the database is not created yet. + await dbContext.Database.MigrateAsync(cancellationToken); + return true; + } + //copied from NpgsqlDatabaseCreator.Exists + catch (PostgresException e) when (e is { SqlState: "3D000" }) + { + return false; + } + catch (NpgsqlException e) when (e.InnerException is IOException + { + InnerException: SocketException + { + SocketErrorCode: SocketError.ConnectionReset + } + }) + { + return false; + } + } + public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/backend/Testing/Fixtures/TestingServicesFixture.cs b/backend/Testing/Fixtures/TestingServicesFixture.cs index 537cb22bf..32abc7e27 100644 --- a/backend/Testing/Fixtures/TestingServicesFixture.cs +++ b/backend/Testing/Fixtures/TestingServicesFixture.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Logging; +using Npgsql; namespace Testing.Fixtures; @@ -13,17 +14,29 @@ namespace Testing.Fixtures; public class TestingServicesFixture : IAsyncLifetime, ICollectionFixture { private readonly ServiceProvider _serviceProvider; - public TestingServicesFixture() + private readonly string _dbName; + + private TestingServicesFixture(string dbName) { + _dbName = dbName; _serviceProvider = ConfigureServices(_ => { }); } - private static void ConfigureBaseServices(IServiceCollection services) + public TestingServicesFixture(): this("lexbox-tests") + { + } + + public static TestingServicesFixture Create(string dbName) + { + return new TestingServicesFixture(dbName); + } + + private static void ConfigureBaseServices(IServiceCollection services, string dbName) { services.AddOptions().Configure(config => { config.LexBoxConnectionString = string.Join(";", - "Database=lexbox-tests", + $"Database={dbName}", "Host=localhost", "Port=5433", "Username=postgres", @@ -42,7 +55,7 @@ private static void ConfigureBaseServices(IServiceCollection services) public ServiceProvider ConfigureServices(Action? configureServices = null) { var services = new ServiceCollection(); - ConfigureBaseServices(services); + ConfigureBaseServices(services, _dbName); configureServices?.Invoke(services); return services.BuildServiceProvider(); } diff --git a/backend/Testing/Fixtures/Tests/ServicesFixtureTests.cs b/backend/Testing/Fixtures/Tests/ServicesFixtureTests.cs new file mode 100644 index 000000000..519c396f8 --- /dev/null +++ b/backend/Testing/Fixtures/Tests/ServicesFixtureTests.cs @@ -0,0 +1,18 @@ +using Shouldly; + +namespace Testing.Fixtures.Tests; + +public class ServicesFixtureTests +{ + [Fact] + public async Task CanSetupServices() + { + var fixture = TestingServicesFixture.Create("lexbox-service-fixture-test"); + var act = async () => + { + await fixture.InitializeAsync(); + await fixture.DisposeAsync(); + }; + Should.CompleteIn(act, TimeSpan.FromSeconds(10)); + } +} diff --git a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs index 86436ee91..8f2363e9a 100644 --- a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs +++ b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs @@ -133,14 +133,8 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol) var srResult = _sendReceiveService.SendReceiveProject(sendReceiveParams, AdminAuth); // First, save the current value of `hg tip` from the original project - var tipUri = new UriBuilder - { - Scheme = TestingEnvironmentVariables.HttpScheme, - Host = TestingEnvironmentVariables.ServerHostname, - Path = $"hg/{projectConfig.Code}/tags", - Query = "?style=json" - }; - var response = await _adminApiTester.HttpClient.GetAsync(tipUri.Uri); + var tipUri = $"/hg/{projectConfig.Code}/tags?style=json"; + var response = await _adminApiTester.HttpClient.GetAsync(tipUri); var jsonResult = await response.Content.ReadFromJsonAsync(); var originalTip = jsonResult?["node"]?.AsValue()?.ToString(); originalTip.ShouldNotBeNull(); @@ -155,7 +149,7 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol) await _adminApiTester.HttpClient.PostAsync($"{_adminApiTester.BaseUrl}/api/project/finishResetProject/{projectConfig.Code}", null); // Step 2: verify project is now empty, i.e. tip is "0000000..." - response = await _adminApiTester.HttpClient.GetAsync(tipUri.Uri); + response = await _adminApiTester.HttpClient.GetAsync(tipUri); jsonResult = await response.Content.ReadFromJsonAsync(); var emptyTip = jsonResult?["node"]?.AsValue()?.ToString(); emptyTip.ShouldNotBeNull(); @@ -178,7 +172,7 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol) _output.WriteLine(srResultStep3); // Step 4: verify project tip is same hash as original project tip - response = await _adminApiTester.HttpClient.GetAsync(tipUri.Uri); + response = await _adminApiTester.HttpClient.GetAsync(tipUri); jsonResult = await response.Content.ReadFromJsonAsync(); var postSRTip = jsonResult?["node"]?.AsValue()?.ToString(); postSRTip.ShouldNotBeNull(); diff --git a/backend/Testing/Testing.csproj b/backend/Testing/Testing.csproj index 6e8e6d7b1..b416bb438 100644 --- a/backend/Testing/Testing.csproj +++ b/backend/Testing/Testing.csproj @@ -15,7 +15,7 @@ - + @@ -48,6 +48,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/deployment/Taskfile.yml b/deployment/Taskfile.yml index 70d861614..990a841a0 100644 --- a/deployment/Taskfile.yml +++ b/deployment/Taskfile.yml @@ -32,6 +32,7 @@ tasks: - local-maildev-forward - local-maildev-smtp-forward - local-aspire-ui-forward + - local-pgadmin-forward backend-forward: interactive: true deps: [infra-forward, local-api-forward] @@ -67,6 +68,10 @@ tasks: internal: true cmds: - kubectl port-forward service/lexbox 1025:1025 -n languagedepot --context docker-desktop + local-pgadmin-forward: + internal: true + cmds: + - kubectl port-forward service/pgadmin 4810:80 -n languagedepot --context docker-desktop local-api-forward: cmds: diff --git a/deployment/gha/app-config.yaml b/deployment/gha/app-config.yaml new file mode 100644 index 000000000..7cf3f5cfe --- /dev/null +++ b/deployment/gha/app-config.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config +data: + environment-name: "Development" diff --git a/deployment/gha/change-storage-class.patch.yaml b/deployment/gha/change-storage-class.patch.yaml new file mode 100644 index 000000000..0a0496142 --- /dev/null +++ b/deployment/gha/change-storage-class.patch.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: hg-repos + namespace: languagedepot +spec: + storageClassName: standard # Because kind only supports the standard storage class diff --git a/deployment/gha/kind.yaml b/deployment/gha/kind.yaml new file mode 100644 index 000000000..18eb9ae2e --- /dev/null +++ b/deployment/gha/kind.yaml @@ -0,0 +1,2 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 diff --git a/deployment/gha/kustomization.yaml b/deployment/gha/kustomization.yaml new file mode 100644 index 000000000..d3a7091e4 --- /dev/null +++ b/deployment/gha/kustomization.yaml @@ -0,0 +1,24 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: languagedepot + +resources: +- ../local-dev/ + +patches: + - path: lexbox.patch.yaml + - target: + version: v1 + kind: PersistentVolumeClaim + path: change-storage-class.patch.yaml + - path: app-config.yaml + +images: + - name: local-dev-init #revert change made by local-dev patch + newName: busybox + - name: ghcr.io/sillsdev/lexbox-api + newTag: develop #will be replaced by workflow + - name: ghcr.io/sillsdev/lexbox-ui + newTag: develop + - name: ghcr.io/sillsdev/lexbox-hgweb + newTag: latest diff --git a/deployment/gha/lexbox.patch.yaml b/deployment/gha/lexbox.patch.yaml new file mode 100644 index 000000000..538e31a32 --- /dev/null +++ b/deployment/gha/lexbox.patch.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lexbox + namespace: languagedepot +spec: + template: + spec: + containers: + - name: lexbox-api + volumeMounts: + - mountPath: /frontend + name: gql-schema + - mountPath: /var/www + name: www + - name: otel-collector + env: #don't try to export to honeycomb + - name: COLLECTOR_CONFIG_OVERRIDE + value: | + exporters: + otlp/aspire: + endpoint: localhost:18889 + tls: + insecure: true + service: + pipelines: + traces: + exporters: [otlp/aspire] + metrics: + exporters: [otlp/aspire] + logs: + exporters: [otlp/aspire] + volumes: + - name: gql-schema + emptyDir: {} + - name: www + emptyDir: {} diff --git a/deployment/local-dev/kustomization.yaml b/deployment/local-dev/kustomization.yaml index 37d03473b..02e526433 100644 --- a/deployment/local-dev/kustomization.yaml +++ b/deployment/local-dev/kustomization.yaml @@ -9,6 +9,7 @@ resources: - db-secrets.yaml - lf-classic-secrets.yaml - self-signed-ssl.yaml +- pgadmin-deployment.yaml components: - ../init-repos diff --git a/deployment/local-dev/lexbox-deployment.patch.yaml b/deployment/local-dev/lexbox-deployment.patch.yaml index 7f543b830..f6a8fe034 100644 --- a/deployment/local-dev/lexbox-deployment.patch.yaml +++ b/deployment/local-dev/lexbox-deployment.patch.yaml @@ -13,6 +13,9 @@ spec: containers: - name: lexbox-api imagePullPolicy: IfNotPresent + startupProbe: + # don't use the startup probe for local dev as it blocks skaffold from showing results until watch has started the app, which takes a while + $patch: delete resources: requests: memory: 2Gi diff --git a/deployment/local-dev/local.env.template b/deployment/local-dev/local.env.template new file mode 100644 index 000000000..693a13e9c --- /dev/null +++ b/deployment/local-dev/local.env.template @@ -0,0 +1,4 @@ +HONEYCOMB_API_KEY=__REPLACE__ +#OTEL_SDK_DISABLED=true +GOOGLE_OAUTH_CLIENT_ID=__REPLACE__.apps.googleusercontent.com +GOOGLE_OAUTH_CLIENT_SECRET=__REPLACE__ diff --git a/deployment/local-dev/pgadmin-deployment.yaml b/deployment/local-dev/pgadmin-deployment.yaml new file mode 100644 index 000000000..e2d88d9e2 --- /dev/null +++ b/deployment/local-dev/pgadmin-deployment.yaml @@ -0,0 +1,113 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: pgadmin-config +data: + servers.json: | + { + "Servers": { + "1": { + "Name": "lexbox", + "Group": "Servers", + "Port": 5432, + "Username": "postgres", + "PassFile": "/lexbox", + "Host": "db", + "SSLMode": "prefer", + "MaintenanceDB": "postgres" + } + } + } +--- +apiVersion: v1 +kind: Service +metadata: + name: pgadmin + namespace: languagedepot + labels: + app: pgadmin +spec: + type: ClusterIP + clusterIP: None + selector: + app: pgadmin + ports: + - name: http + protocol: TCP + port: 80 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pgadmin + namespace: languagedepot + labels: + app: pgadmin +spec: + selector: + matchLabels: + app: pgadmin + strategy: + type: Recreate + template: + metadata: + labels: + app: pgadmin + spec: + volumes: + - name: pgadmin-config + configMap: + name: pgadmin-config + containers: + - name: pgadmin + image: dpage/pgadmin4:8.10 + # pgadmin needs a specific format for its pgpass files + # Can't just mount k8s secret, so have to run some pre-entrypoint code instead + command: ["/bin/sh"] + args: + - "-c" + - | + mkdir -p /tmp/pgadmin/admin_test.com + echo "${POSTGRES_HOSTNAME}:${POSTGRES_PORT}:*:${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}" > /tmp/pgadmin/admin_test.com/lexbox + chown -R pgadmin /tmp/pgadmin/admin_test.com + chmod 700 /tmp/pgadmin/admin_test.com + chmod 600 /tmp/pgadmin/admin_test.com/lexbox + ls -l /tmp/pgadmin/admin_test.com/lexbox + /entrypoint.sh "$@" + volumeMounts: + - name: pgadmin-config + mountPath: /pgadmin4/servers.json + subPath: servers.json + readOnly: true + # TODO: After testing, create a persistent volume claim and mount it here + # - name: pgadmin-data + # mountPath: /var/lib/pgadmin + env: + - name: PGADMIN_CONFIG_STORAGE_DIR + # pgadmin wants quotes around this string, so we need to quote the quotes so YAML will pass the inner quotes through + value: "'/tmp/pgadmin'" + - name: PGADMIN_LISTEN_ADDRESS + value: "0.0.0.0" + # Don't want pgadmin to run a Postfix instance, we don't need password-reset emails + - name: PGADMIN_DISABLE_POSTFIX + value: "true" + - name: PGADMIN_DEFAULT_EMAIL + value: admin@test.com + - name: PGADMIN_DEFAULT_PASSWORD + value: pass + - name: POSTGRES_HOSTNAME + value: db + - name: POSTGRES_PORT + value: "5432" + - name: POSTGRES_USERNAME + value: "postgres" + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db + key: POSTGRES_PASSWORD + ports: + - name: http + protocol: TCP + containerPort: 80 diff --git a/frontend/viewer/src/CrdtProjectView.svelte b/frontend/viewer/src/CrdtProjectView.svelte index 6e1ea2750..05c9164bf 100644 --- a/frontend/viewer/src/CrdtProjectView.svelte +++ b/frontend/viewer/src/CrdtProjectView.svelte @@ -14,7 +14,6 @@ .then(() => connected = (connection.state == HubConnectionState.Connected)) .catch(err => console.error(err)); onDestroy(() => connection.stop()); - setContext('project-name', projectName); SetupSignalR(connection, { history: true, write: true, diff --git a/frontend/viewer/src/FwDataProjectView.svelte b/frontend/viewer/src/FwDataProjectView.svelte index 37d909896..a5c565bbd 100644 --- a/frontend/viewer/src/FwDataProjectView.svelte +++ b/frontend/viewer/src/FwDataProjectView.svelte @@ -10,7 +10,6 @@ import {Entry} from './lib/mini-lcm'; export let projectName: string; - setContext('project-name', projectName); const connection = new HubConnectionBuilder() .withUrl(`/api/hub/${projectName}/fwdata`) .withAutomaticReconnect() diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index b4c2a8d17..f0d4517d6 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -11,13 +11,12 @@ import Editor from './lib/Editor.svelte'; import {navigate} from 'svelte-routing'; import {headword, pickBestAlternative} from './lib/utils'; - import {views} from './lib/config-data'; import {useLexboxApi} from './lib/services/service-provider'; import type {IEntry} from './lib/mini-lcm'; import {onMount, setContext} from 'svelte'; import {derived, writable, type Readable} from 'svelte/store'; import {deriveAsync, makeDebouncer} from './lib/utils/time'; - import {type ViewConfig, type LexboxPermissions, type ViewOptions, type LexboxFeatures} from './lib/config-types'; + import { type LexboxPermissions, type LexboxFeatures} from './lib/config-types'; import ViewOptionsDrawer from './lib/layout/ViewOptionsDrawer.svelte'; import EntryList from './lib/layout/EntryList.svelte'; import Toc from './lib/layout/Toc.svelte'; @@ -33,6 +32,9 @@ import { saveEventDispatcher, saveHandler } from './lib/services/save-event-service'; import {AppNotification} from './lib/notifications/notifications'; import flexLogo from './lib/assets/flex-logo.png'; + import {initView, initViewSettings} from './lib/services/view-service'; + import {views} from './lib/entry-editor/view-data'; + import {initWritingSystems} from './lib/writing-systems'; export let loading = false; @@ -47,25 +49,14 @@ comment: true, }); - const options = writable({ - showExtraFields: false, - hideEmptyFields: false, - activeView: views[0], - generateExternalChanges: false, - }); - const viewConfig = derived([options, permissions, features], ([config, permissions, features]) => { - const readonly = !permissions.write || !features.write; - return { - ...config, - readonly, - hideEmptyFields: config.hideEmptyFields || readonly, - }; - }); + const readonly = !$permissions.write || !$features.write; - setContext>('viewConfig', viewConfig); + const currentView = initView(views[0]); + const viewSettings = initViewSettings({hideEmptyFields: false}); export let projectName: string; + setContext('project-name', projectName); export let isConnected: boolean; export let showHomeButton = true; $: connected.set(isConnected); @@ -79,30 +70,16 @@ setContext('selectedIndexExamplar', selectedIndexExemplar); $: updateSearchParam(ViewerSearchParam.IndexCharacter, $selectedIndexExemplar); - const { value: writingSystems } = deriveAsync(connected, isConnected => { + const writingSystems = initWritingSystems(deriveAsync(connected, isConnected => { if (!isConnected) return Promise.resolve(null); return lexboxApi.GetWritingSystems(); - }); - setContext('writingSystems', writingSystems); + }).value); const indexExamplars = derived(writingSystems, wsList => { return wsList?.vernacular[0].exemplars; }); setContext('indexExamplars', indexExamplars); const trigger = writable(0); - const { value: partsOfSpeech } = deriveAsync(connected, isConnected => { - if (!isConnected) return Promise.resolve(null); - return lexboxApi.GetPartsOfSpeech(); - }); - const { value: semanticDomains } = deriveAsync(connected, isConnected => { - if (!isConnected) return Promise.resolve(null); - return lexboxApi.GetSemanticDomains(); - }); - const optionProvider: OptionProvider = { - partsOfSpeech: derived([writingSystems, partsOfSpeech], ([ws, pos]) => pos?.map(option => ({ value: option.id, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), - semanticDomains: derived([writingSystems, semanticDomains], ([ws, sd]) => sd?.map(option => ({ value: option.id, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), - }; - setContext('optionProvider', optionProvider); const { value: _entries, loading: loadingEntries, flush: flushLoadingEntries } = deriveAsync(derived([search, connected, selectedIndexExemplar, trigger], s => s), ([s, isConnected, exemplar]) => { @@ -179,21 +156,18 @@ navigateToEntryIdOnLoad = undefined; } - $: _loading = !$entries || !$writingSystems || !$partsOfSpeech || !$semanticDomains || loading; + $: _loading = !$entries || !$writingSystems || loading; function onEntryCreated(entry: IEntry) { $entries?.push(entry);//need to add it before refresh, otherwise it won't get selected because it's not in the list - navigateToEntry(entry); + navigateToEntry(entry, headword(entry)); } - function navigateToEntry(entry: IEntry) { - $search = ''; - selectEntry(entry); - } - - function selectEntry(entry: IEntry) { + function navigateToEntry(entry: IEntry, searchText?: string) { + // this is to ensure that the selected entry is in the list of entries, otherwise it won't be selected + $search = searchText ?? ''; + $selectedIndexExemplar = undefined; $selectedEntry = entry; - $selectedIndexExemplar = headword(entry).charAt(0).toLocaleUpperCase() || undefined; refreshEntries(); pickedEntry = true; } @@ -231,6 +205,12 @@ callback: () => window.location.reload() }); } + let newEntryDialog: NewEntryDialog; + function openNewEntryDialog(text: string) { + const defaultWs = $writingSystems?.vernacular[0].id; + if (defaultWs === undefined) return; + newEntryDialog.openWithValue({lexemeForm: {[defaultWs]: text}}); + } @@ -262,12 +242,14 @@
- navigateToEntry(e.detail)} /> + navigateToEntry(e.detail.entry, e.detail.search)} + createNew={newEntryDialog !== undefined} + on:createNew={(e) => openNewEntryDialog(e.detail)} />
- {#if !$viewConfig.readonly} - onEntryCreated(e.detail.entry)} /> + {#if !readonly} + onEntryCreated(e.detail.entry)} /> {/if}
{#if $features.history} - + {/if} {#if $features.feedback}
History
-
+
-
-
+
+
{#if !history || history.length === 0}
No history found
{:else} @@ -94,19 +94,21 @@ {/if}
- {#if record?.entity} - {#if record.entityName === "Entry"} - - {:else if record.entityName === "Sense"} -
- -
- {:else if record.entityName === "ExampleSentence"} -
- -
+
+ {#if record?.entity} + {#if record.entityName === "Entry"} + + {:else if record.entityName === "Sense"} +
+ +
+ {:else if record.entityName === "ExampleSentence"} +
+ +
+ {/if} {/if} - {/if} +
diff --git a/frontend/viewer/src/lib/i18n.ts b/frontend/viewer/src/lib/i18n.ts index eb17039e2..999d9dc68 100644 --- a/frontend/viewer/src/lib/i18n.ts +++ b/frontend/viewer/src/lib/i18n.ts @@ -1,10 +1,14 @@ import type { FieldConfig, WellKnownFieldId } from './config-types'; +import type {FieldIds} from './entry-editor/field-data'; type I18n = Record & Record, string>; -type I18nKey = keyof typeof defaultI18n; -export type I18nType = keyof typeof i18nMap.other; +type I18nKey = FieldIds; +/** + * I18n type is used to specify which i18n group to use for a field. If empty, the default i18n is used. + */ +export type I18nType = 'weSay' | 'languageForge' | ''; -const defaultI18n = { +const defaultI18n: Record = { 'lexemeForm': 'Lexeme form', 'citationForm': 'Citation form', 'literalMeaning': 'Literal meaning', @@ -30,25 +34,16 @@ const languageForgeI18n = { 'partOfSpeechId': 'Part of speech', }; -const i18nMap = ({ - base: defaultI18n, - other: { - weSay: weSayI18n, - languageForge: languageForgeI18n, - }, -} as const) satisfies { - base: I18n, - other: Record>, +const i18nMap: Record, Partial>> = { + weSay: weSayI18n, + languageForge: languageForgeI18n, }; export function i18n(key: I18nKey, i18nType?: I18nType): string { - const currI18n = i18nType ? { - ...defaultI18n, - ...i18nMap.other[i18nType], - } : defaultI18n; - return currI18n[key]; + if (!i18nType) return defaultI18n[key]; + return i18nMap[i18nType][key] ?? defaultI18n[key]; } -export function fieldName(fieldConfig: FieldConfig, i18nType?: I18nType): string { - return 'name' in fieldConfig ? fieldConfig.name : i18n(fieldConfig.id as WellKnownFieldId, i18nType); +export function fieldName(fieldConfig: {id: string, name?: string}, i18nType?: I18nType): string { + return fieldConfig.name ?? i18n(fieldConfig.id as WellKnownFieldId, i18nType); } diff --git a/frontend/viewer/src/lib/in-memory-api-service.ts b/frontend/viewer/src/lib/in-memory-api-service.ts index d1f952d1f..5eaac78c4 100644 --- a/frontend/viewer/src/lib/in-memory-api-service.ts +++ b/frontend/viewer/src/lib/in-memory-api-service.ts @@ -7,13 +7,15 @@ LexboxApiFeatures, QueryOptions, WritingSystemType, - WritingSystems + WritingSystems, + PartOfSpeech, + SemanticDomain } from './services/lexbox-api'; import {entries, projectName, writingSystems} from './entry-data'; -import { type WritingSystem } from './mini-lcm'; -import { headword } from './utils'; -import { applyPatch } from 'fast-json-patch'; +import {type WritingSystem} from './mini-lcm'; +import {headword} from './utils'; +import {applyPatch} from 'fast-json-patch'; function filterEntries(entries: IEntry[], query: string) { return entries.filter(entry => @@ -23,10 +25,23 @@ function filterEntries(entries: IEntry[], query: string) { ...entry.senses.flatMap(sense => [ ...Object.values(sense.gloss ?? {}), ]), - ].some(value => value?.toLowerCase().includes(query.toLowerCase()))) + ].some(value => value?.toLowerCase().includes(query.toLowerCase()))); } export class InMemoryApiService implements LexboxApiClient { + GetPartsOfSpeech(): Promise { + return Promise.resolve([ + {id: 'noun', name: {en: 'noun'},}, + {id: 'verb', name: {en: 'verb'},} + ]); + } + + GetSemanticDomains(): Promise { + return Promise.resolve([ + {id: 'Fruit', name: {en: 'Fruit'}, code: '1'}, + {id: 'Animal', name: {en: 'Animal'}, code: '2'}, + ]); + } SupportedFeatures(): LexboxApiFeatures { return {}; @@ -35,6 +50,7 @@ export class InMemoryApiService implements LexboxApiClient { readonly projectName = projectName; private _entries = entries; + private _Entries(): IEntry[] { return JSON.parse(JSON.stringify(this._entries)); } diff --git a/frontend/viewer/src/lib/layout/FieldListDialog.svelte b/frontend/viewer/src/lib/layout/FieldListDialog.svelte index 39bfc604e..8c01a03c2 100644 --- a/frontend/viewer/src/lib/layout/FieldListDialog.svelte +++ b/frontend/viewer/src/lib/layout/FieldListDialog.svelte @@ -2,17 +2,15 @@ import { mdiMagnify } from "@mdi/js"; import { Checkbox, Dialog, ListItem, TextField, cls } from "svelte-ux"; import { fieldName } from "../i18n"; - import { allFields } from "../config-data"; - import { getContext } from "svelte"; - import type { Writable } from "svelte/store"; - import type { ViewConfig } from "../config-types"; + import type {FieldConfig} from '../config-types'; + import {useCurrentView} from '../services/view-service'; export let open = false; let fieldSearch = ''; + let currentView = useCurrentView(); - const viewConfig = getContext>('viewConfig'); - - $: filteredFields = allFields($viewConfig.activeView).filter( + //todo list all fields + $: filteredFields = ([] as FieldConfig[]).filter( (field) => !fieldSearch || fieldName(field)?.toLocaleLowerCase().includes(fieldSearch.toLocaleLowerCase()) ); @@ -32,7 +30,7 @@ {#each filteredFields as field}