diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index 957e094..12651e6 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -27,19 +27,17 @@ jobs: - name: Set up environment variables run: | - echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV - echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV + echo "IMAGE=ghcr.io/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + repo_name="$(echo ${{ github.repository }} | cut -d '/' -f 2)" - # Set SERVICE_LABEL: derive from GITHUB_REPOSITORY (replace dots with dashes) - echo "SERVICE_LABEL=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '.' '-')" >> $GITHUB_ENV + # Set SERVICE: derive from repo name (replace dots with dashes) + echo "SERVICE=$(echo $repo_name | tr '[:upper:]' '[:lower:]' | tr '.' '-')" >> $GITHUB_ENV # Set KAMAL_DEPLOY_HOST: use secret if available, otherwise use repository name if [ -n "${{ secrets.KAMAL_DEPLOY_HOST }}" ]; then DEPLOY_HOST="${{ secrets.KAMAL_DEPLOY_HOST }}" else - DEPLOY_HOST="$(echo ${{ github.repository }} | cut -d '/' -f 2)" + DEPLOY_HOST="$repo_name" fi # Validate KAMAL_DEPLOY_HOST contains at least one '.' @@ -56,7 +54,7 @@ jobs: KAMAL_DEPLOY_IP: ${{ secrets.KAMAL_DEPLOY_IP }} if: env.KAMAL_DEPLOY_IP != null run: | - sed -i 's###g' MyApp/MyApp.csproj + sed -i 's###g' MyApp/MyApp.csproj - name: Check for Client directory and package.json id: check_client @@ -83,20 +81,6 @@ jobs: working-directory: ./MyApp.Client run: npm run build - - name: Install x tool - run: dotnet tool install -g x - - - name: Apply Production AppSettings - env: - APPSETTINGS_PATCH: ${{ secrets.APPSETTINGS_PATCH }} - if: env.APPSETTINGS_PATCH != null - working-directory: ./MyApp - run: | - cat <> appsettings.json.patch - ${{ secrets.APPSETTINGS_PATCH }} - EOF - x patch appsettings.json.patch - - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -115,7 +99,7 @@ jobs: KAMAL_DEPLOY_HOST: ${{ secrets.KAMAL_DEPLOY_HOST }} run: | dotnet publish --os linux --arch x64 -c Release \ - -p:ContainerRepository=${{ env.image_repository_name }} \ + -p:ContainerRepository=${{ env.IMAGE }} \ -p:ContainerRegistry=ghcr.io -p:ContainerImageTags=latest \ -p:ContainerPort=80 \ -p:ContainerEnvironmentVariable="SERVICESTACK_LICENSE=${{ env.SERVICESTACK_LICENSE }}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d40e0e0..bf26f5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,8 +18,8 @@ jobs: with: dotnet-version: 10.0.x - - name: Restore NuGet packages (use repo NuGet.config) - run: dotnet restore MyApp.slnx --configfile ./NuGet.Config + - name: Restore NuGet packages + run: dotnet restore MyApp.slnx # If your feed requires authentication, enable and configure the step below. # This example uses a Personal Access Token stored in secrets.NUGET_API_KEY. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e97724..a9631c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ on: env: DOCKER_BUILDKIT: 1 SERVICESTACK_LICENSE: ${{ secrets.SERVICESTACK_LICENSE }} + APPSETTINGS_JSON: ${{ secrets.APPSETTINGS_JSON }} KAMAL_DEPLOY_IP: ${{ secrets.KAMAL_DEPLOY_IP }} KAMAL_DEPLOY_HOST: ${{ secrets.KAMAL_DEPLOY_HOST }} KAMAL_REGISTRY_USERNAME: ${{ github.actor }} @@ -28,12 +29,18 @@ jobs: - name: Checkout code uses: actions/checkout@v5 + - name: Encode APPSETTINGS_JSON for runtime + if: env.APPSETTINGS_JSON != null + run: | + # Base64 encode to avoid shell/YAML quoting issues; keep as a single env var. + b64=$(printf '%s' "$APPSETTINGS_JSON" | base64 -w0) + echo "APPSETTINGS_JSON_BASE64=$b64" >> $GITHUB_ENV + - name: Set up environment variables run: | - echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV - echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV + echo "IMAGE=ghcr.io/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + repo_name="$(echo ${{ github.repository }} | cut -d '/' -f 2)" + echo "SERVICE=$(echo $repo_name | tr '[:upper:]' '[:lower:]' | tr '.' '-')" >> $GITHUB_ENV if find . -maxdepth 2 -type f -name "Configure.Db.Migrations.cs" | grep -q .; then echo "HAS_MIGRATIONS=true" >> $GITHUB_ENV else @@ -73,14 +80,14 @@ jobs: - name: Ensure directories exist with correct permissions run: | echo "Creating directories with correct permissions" - kamal server exec "mkdir -p /opt/docker/${{ env.repository_name }}/App_Data /opt/docker/${{ env.repository_name }}/initdb.d" + kamal server exec "mkdir -p /opt/docker/${{ env.SERVICE }}/App_Data /opt/docker/${{ env.SERVICE }}/initdb.d" echo "Setting app file permissions" - kamal server exec "chown -R 1654:1654 /opt/docker/${{ env.repository_name }}/App_Data /opt/docker/${{ env.repository_name }}/initdb.d" + kamal server exec "chown -R 1654:1654 /opt/docker/${{ env.SERVICE }}/App_Data /opt/docker/${{ env.SERVICE }}/initdb.d" - name: Check if first run and execute kamal app boot if necessary run: | - FIRST_RUN_FILE="~/first-run/${{ env.repository_name }}" + FIRST_RUN_FILE="~/first-run/${{ env.SERVICE }}" if ! kamal server exec -q "test -f $FIRST_RUN_FILE"; then kamal server exec -q "mkdir -p ~/first-run && touch $FIRST_RUN_FILE" || true @@ -88,7 +95,7 @@ jobs: echo "Initializing DB with INIT_DB_SQL secret..." # Save the SQL content to a temporary file echo "${{ env.INIT_DB_SQL }}" > init-db.sql - cat init-db.sql | kamal server exec -i "cat > /opt/docker/${{ env.repository_name }}/initdb.d/${{ env.repository_name }}.sql" && rm init-db.sql || true + cat init-db.sql | kamal server exec -i "cat > /opt/docker/${{ env.SERVICE }}/initdb.d/${{ env.SERVICE }}.sql" && rm init-db.sql || true fi # Start all kamal accessories kamal accessory boot all || true @@ -101,13 +108,13 @@ jobs: - name: Verify file permissions before deploy run: | - kamal server exec --no-interactive "chown -R 1654:1654 /opt/docker/${{ env.repository_name }}/App_Data /opt/docker/${{ env.repository_name }}/initdb.d" + kamal server exec --no-interactive "chown -R 1654:1654 /opt/docker/${{ env.SERVICE }}/App_Data /opt/docker/${{ env.SERVICE }}/initdb.d" - name: Deploy with Kamal run: | kamal lock release -v kamal server exec --no-interactive 'echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin' - kamal server exec --no-interactive 'docker pull ghcr.io/${{ env.image_repository_name }}:latest' + kamal server exec --no-interactive 'docker pull ${{ env.IMAGE }}:latest' kamal deploy -P --version latest - name: Migration diff --git a/.gitignore b/.gitignore index 0de7e51..3baccdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,48 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Custom +# dotnet +.env dist/ -coverage/ -package-lock.json App_Data/ wwwroot/ - -# User-specific files -*.suo +.vscode/ +.idea/ *.user -*.userosscache -*.sln.docstates +*.tsbuildinfo +#sqlite +.vs/ +bin/ +obj/ +*.db +*.db-shm +*.db-wal +*.db-journal +*.sqlite +*.sqlite +*.sqlite-shm +*.sqlite-wal +Configure.secrets.cs +appsettings.Production.* + +# dependencies +node_modules/ +.pnp +.pnp.js -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist # Build results [Dd]ebug/ @@ -26,272 +51,44 @@ wwwroot/ [Rr]eleases/ x64/ x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ - -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c +[Ll]ogs/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ -#**/Properties/launchSettings.json - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ +# ASP.NET Scaffolding +ScaffoldingReadMe.txt # NuGet Packages *.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef +# NuGet Symbol Packages +*.snupkg -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ +# dotenv environment variables file +.env # Others -ClientBin/ ~$* *~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Typescript v1 declaration files -typings/ +CodeCoverage/ -# Visual Studio 6 build log -*.plg +# MSBuild Binary and Structured Log +*.binlog -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Telerik's JustMock configuration file -*.jmconfig +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs -MyApp/Configure.secrets.cs -mise.toml +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml \ No newline at end of file diff --git a/.kamal/secrets b/.kamal/secrets index bd4a025..cba9223 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -6,6 +6,7 @@ KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME SERVICESTACK_LICENSE=$SERVICESTACK_LICENSE +APPSETTINGS_JSON_BASE64=$APPSETTINGS_JSON_BASE64 # Option 2: Read secrets via a command # RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d6c3590 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,604 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) and other Agents when working with code in this repository. + +## Project Overview + +A full-stack .NET 10 + React 19 + Vite template combining ServiceStack backend with React static site generation. Uses ASP.NET Core Identity for auth, OrmLite for application data, and Entity Framework Core for Identity data management. + +## Common Commands + +### Development + +```bash +# Start both .NET and Vite dev servers (from project root) +dotnet watch + +# After making changes to C# DTOs restart .NET before regenerating TypeScript DTOs by running: +cd MyApp.Client && npm run dtos +``` + +### Building + +```bash +# Build frontend (TypeScript + Vite) +cd MyApp.Client && npm run build + +# Build backend (.NET) +dotnet build + +# Build Tailwind CSS for Razor Pages +cd MyApp && npm run ui:build +``` + +### Testing + +```bash +# Frontend tests (Vitest) +cd MyApp.Client && npm run test # Watch mode +cd MyApp.Client && npm run test:ui # UI mode +cd MyApp.Client && npm run test:run # Single run + +# Backend tests (NUnit) +dotnet test +``` + +### Database Migrations +```bash +# Run all migrations (both EF Core and OrmLite) +cd MyApp && npm run migrate + +# Entity Framework migrations (for changes to Identity tables) +dotnet ef migrations add MigrationName +dotnet ef database update + +# Revert last migration +cd MyApp && npm run revert:last + +# Drop and re-run last migration (useful during development) +cd MyApp && npm run rerun:last +``` + +### AutoQuery CRUD Development (using okai) + +```bash +# Create new AutoQuery CRUD feature with TypeScript data model +npx okai init Table + +# Regenerate C# AutoQuery APIs and DB migration from .d.ts model +npx okai Table.d.ts + +# Remove AutoQuery feature and all generated code +npx okai rm Table.d.ts +``` + +## Architecture + +### Hybrid Development/Production Model + +**Development Mode:** +- `dotnet watch` from MyApp starts .NET (port 5001) and Vite dev server (port 5173), accessible via `https://localhost:5001` +- ASP.NET Core proxies requests to Vite dev server via `NodeProxy` (configured in [Program.cs](MyApp/Program.cs#L41)) +- Hot Module Replacement (HMR) enabled via WebSocket proxying using `MapNotFoundToNode`, `MapViteHmr`, `RunNodeProcess`, `MapFallbackToNode` in [Program.cs](MyApp/Program.cs) + +**Production Mode:** +- Vite builds React app to `MyApp.Client/dist/`, which is copied to `MyApp/wwwroot/` when published +- ASP.NET Core serves static files directly from `wwwroot` - no Node.js required +- Fallback to `index.html` for client-side routing + +### Modular Startup Configuration + +AppHost uses .NET's `IHostingStartup` pattern to split configuration across multiple files in `MyApp/`: +- [Configure.AppHost.cs](MyApp/Configure.AppHost.cs) - Main ServiceStack AppHost registration +- [Configure.Auth.cs](MyApp/Configure.Auth.cs) - ServiceStack AuthFeature with ASP.NET Core Identity integration +- [Configure.AutoQuery.cs](MyApp/Configure.AutoQuery.cs) - AutoQuery features and audit events +- [Configure.Db.cs](MyApp/Configure.Db.cs) - Database setup (OrmLite for app data, EF Core for Identity) +- [Configure.Db.Migrations.cs](MyApp/Configure.Db.Migrations.cs) - Runs OrmLite and EF DB Migrations and creates initial users +- [Configure.BackgroundJobs.cs](MyApp/Configure.BackgroundJobs.cs) - Background job processing +- [Configure.HealthChecks.cs](MyApp/Configure.HealthChecks.cs) - Health monitoring endpoint + +This pattern keeps [Program.cs](MyApp/Program.cs) clean and separates concerns. Each `Configure.*.cs` file is auto-registered via `[assembly: HostingStartup]` attribute. + +### Project Structure + +``` +MyApp/ # .NET Backend (hosts both .NET and Vite React) +├── Configure.*.cs # Modular startup configuration +├── Migrations/ # EF Core Identity migrations + OrmLite app migrations +├── Pages/ # Identity Auth Razor Pages +└── wwwroot/ # Production static files (from MyApp.Client/dist) + +MyApp.Client/ # React Frontend +├── src/ +│ ├── lib/ +│ │ ├── dtos.ts # Auto-generated from C# (via `npm run dtos`) +│ │ ├── gateway.ts # ServiceStack JsonServiceClient +│ │ └── utils.ts # Utility functions +│ ├── components/ # React components +│ └── styles/ # Tailwind CSS +└── vite.config.ts # Vite config for dev mode + +MyApp.ServiceModel/ # DTOs & API contracts +├── *.cs # C# Request/Response DTOs +├── api.d.ts # TypeScript data models Schema +└── *.d.ts # TypeScript data models for okai code generation + +MyApp.ServiceInterface/ # Service implementations +├── Data/ # EF Core DbContext and Identity models +└── *Services.cs # ServiceStack service implementations + +MyApp.Tests/ # .NET tests (NUnit) +├── IntegrationTest.cs # API integration tests +└── MigrationTasks.cs # Migration task runner + +config/ +└── deploy.yml # Kamal deployment settings +.github/ +└── workflows/ + ├── build.yml # CI build and test + ├── build-container.yml # Container image build + └── release.yml # Production deployment with Kamal +``` + +### Database Architecture + +**Dual ORM Strategy:** +- **OrmLite**: All application data (faster, simpler, typed POCO ORM) +- **Entity Framework Core**: ASP.NET Core Identity tables only (Users, Roles, etc.) + +Both use the same SQLite database by default (`App_Data/app.db`). Connection string in `appsettings.json`. + +**Migration Files:** +- `MyApp/Migrations/20240301000000_CreateIdentitySchema.cs` - EF Core migration for Identity +- `MyApp/Migrations/Migration1000.cs` - OrmLite migration for app tables (e.g., Booking) + +Run `npm run migrate` to execute both. + +### Authentication Flow + +1. ASP.NET Core Identity handles user registration/login via Razor Pages at `/Identity/*` routes +2. ServiceStack AuthFeature integrates with Identity via `IdentityAuth.For()` in [Configure.Auth.cs](MyApp/Configure.Auth.cs) +3. Custom claims added via `AdditionalUserClaimsPrincipalFactory` and `CustomUserSession` +4. ServiceStack services use `[ValidateIsAuthenticated]` and `[ValidateHasRole]` attributes for authorization (see [Bookings.cs](MyApp.ServiceModel/Bookings.cs)) + +### ServiceStack .NET APIs + +ServiceStack APIs adopt a [DTOs-first approach utilizing message-based APIs](https://docs.servicestack.net/api-design). To create ServiceStack APIs create all related DTOs used in the API (aka Service Contracts) into a single file in the `MyApp.ServiceModel` project, e.g: + +```csharp +//MyApp.ServiceModel/Bookings.cs + +public class GetBooking : IGet, IReturn +{ + [ValidateGreaterThan(0)] + public int Id { get; set; } +} +public class GetBookingResponse +{ + public Booking? Result { get; set; } + public ResponseStatus? ResponseStatus { get; set; } +} +``` + +The response type of an API should be specified in the `IReturn` marker interface. APIs which don't return a response should implement `IReturnVoid` instead. + +By convention, APIs return single results in a `T? Result` property, APIs returns multiple results of the same type in a `List Results` property. Otherwise APIs returning results of different types should use intuitive property names in a flat structured Response DTO for simplicity. + +These C# Server DTOs are used to generate the TypeScript `dtos.ts`. + +#### Validating APIs + +Any API Errors are automatically populated in the `ResponseStatus` property, inc. [Declarative Validation Attributes](https://docs.servicestack.net/declarative-validation) like `[ValidateGreaterThan]` and `[ValidateNotEmpty]` which validate APIs and return any error responses in `ResponseStatus`. + +#### Protecting APIs + +The Type Validation Attributes below should be used to protect APIs: + +- `[ValidateIsAuthenticated]` - Only Authenticated Users +- `[ValidateIsAdmin]` - Only Admin Users +- `[ValidateHasRole]` - Only Authenticated Users assigned with the specified role +- `[ValidateApiKey]` - Only Users with a valid API Key + +```csharp +//MyApp.ServiceModel/Bookings.cs +[ValidateHasRole("Employee")] +public class CreateBooking : ICreateDb, IReturn +{ + //... +} +``` + +#### Primary HTTP Method + +APIs have a primary HTTP Method which if not specified uses HTTP **POST**. Use `IGet`, `IPost`, `IPut`, `IPatch` or `IDelete` to change the HTTP Verb except for AutoQuery APIs which have implied verbs for each CRUD operation. + +#### API Implementations + +ServiceStack API implementations should be added to `MyApp.ServiceInterface/`: + +```csharp +//MyApp.ServiceInterface/BookingServices.cs +public class BookingServices(IAutoQueryDb autoquery) : Service +{ + public object Any(GetBooking request) + { + return new GetBookingResponse { + Result = base.Db.SingleById(request.Id) + ?? throw HttpError.NotFound("Booking does not exist") + }; + } + + // Example of overriding an AutoQuery API with a custom implementation + public async Task Any(QueryBookings request) + { + using var db = autoQuery.GetDb(request, base.Request); + var q = autoQuery.CreateQuery(request, base.Request, db); + return await autoQuery.ExecuteAsync(request, q, base.Request, db); + } +} +``` + +APIs can be implemented with **sync** or **async** methods using `Any` or its primary HTTP Method e.g. `Get`, `Post`. +The return type of an API implementation does not change behavior however returning `object` is recommended so its clear the Request DTO `IReturn` interface defines the APIs Response type and Service Contract. + +The ServiceStack `Service` base class has convenience properties like `Db` to resolve an Open `IDbConnection` for that API and `base.Request` to resolve the `IRequest` context. All other dependencies required by the API should use constructor injection in a Primary Constructor. + +A ServiceStack API typically returns the Response DTO defined in its Request DTO `IReturn` or an Error but can also return any raw [custom Return Type](https://docs.servicestack.net/service-return-types) like `string`, `byte[]`, `Stream`, `IStreamWriter`, `HttpResult` and `HttpError`. + +### AutoQuery CRUD Pattern + +ServiceStack's AutoQuery generates full CRUD APIs from declarative request DTOs. Example in [Bookings.cs](MyApp.ServiceModel/Bookings.cs): + +- `QueryBookings : QueryDb` → GET /api/QueryBookings with filtering/sorting/paging +- `CreateBooking : ICreateDb` → POST /api/CreateBooking +- `UpdateBooking : IPatchDb` → PATCH /api/UpdateBooking +- `DeleteBooking : IDeleteDb` → DELETE /api/DeleteBooking + +**No service implementation required** - AutoQuery handles it. Audit fields (`CreatedBy`, `ModifiedBy`, etc.) auto-populated via `[AutoApply(Behavior.AuditCreate)]` attributes. + +[AutoQuery CRUD Docs](https://react-templates.net/docs/autoquery/crud) + +### TypeScript DTO Generation + +After changing C# DTOs in `MyApp.ServiceModel/`, restart the .NET Server then run: +```bash +cd MyApp.Client && npm run dtos +``` + +This calls ServiceStack's `/types/typescript` endpoint and updates `dtos.ts` with type-safe client DTOs. The Vite dev server auto-reloads. + +### okai AutoQuery Code Generation + +The `npx okai` tool generates C# AutoQuery APIs and migrations from TypeScript data models (`.d.ts` files): + +1. **TypeScript data model** (`MyApp.ServiceModel/Bookings.d.ts`) defines the entity with decorators +2. **C# AutoQuery APIs** (`MyApp.ServiceModel/Bookings.cs`) - auto-generated CRUD request/response DTOs +3. **C# OrmLite migration** (`MyApp/Migrations/Migration1000.cs`) - auto-generated schema creation + +This enables rapid prototyping: edit the `.d.ts` model, run `npx okai Bookings.d.ts`, then `npm run migrate`. + +**Important:** The `.d.ts` files use special decorators (e.g., `@validateHasRole`, `@autoIncrement`) that map to C# attributes and .NET Types. The valid schema for these is defined in [api.d.ts](MyApp.ServiceModel/api.d.ts). Reference [Bookings.d.ts](MyApp.ServiceModel/Bookings.d.ts) for examples. + +### AutoQuery APIs + +[C# AutoQuery APIs](https://react-templates.net/docs/autoquery/querying) allow creating queryable C# APIs for RDBMS Tables with just a Request DTO definition, e.g: + +```csharp +public class QueryBookings : QueryDb +{ + public int? Id { get; set; } + public decimal? MinCost { get; set; } + public List? CostBetween { get; set; } + public List? Ids { get; set; } +} +``` + +It uses these conventions to determine the behavior of each property filter: + +```csharp +ImplicitConventions = new() { + {"%Above%", "{Field} > {Value}"}, + {"Begin%", "{Field} > {Value}"}, + {"%Beyond%", "{Field} > {Value}"}, + {"%Over%", "{Field} > {Value}"}, + {"%OlderThan", "{Field} > {Value}"}, + {"%After%", "{Field} > {Value}"}, + {"OnOrAfter%", "{Field} >= {Value}"}, + {"%From%", "{Field} >= {Value}"}, + {"Since%", "{Field} >= {Value}"}, + {"Start%", "{Field} >= {Value}"}, + {"%Higher%", "{Field} >= {Value}"}, + {"Min%", "{Field} >= {Value}"}, + {"Minimum%", "{Field} >= {Value}"}, + {"Behind%", "{Field} < {Value}"}, + {"%Below%", "{Field} < {Value}"}, + {"%Under%", "{Field} < {Value}"}, + {"%Lower%", "{Field} < {Value}"}, + {"%Before%", "{Field} < {Value}"}, + {"%YoungerThan", "{Field} < {Value}"}, + {"OnOrBefore%", "{Field} < {Value}"}, + {"End%", "{Field} < {Value}"}, + {"Stop%", "{Field} < {Value}"}, + {"To%", "{Field} < {Value}"}, + {"Until%", "{Field} < {Value}"}, + {"Max%", "{Field} < {Value}"}, + {"Maximum%", "{Field} < {Value}"}, + + {"%GreaterThanOrEqualTo%", "{Field} >= {Value}"}, + {"%GreaterThan%", "{Field} > {Value}"}, + {"%LessThan%", "{Field} < {Value}"}, + {"%LessThanOrEqualTo%", "{Field} < {Value}"}, + {"%NotEqualTo", "{Field} <> {Value}"}, + + {"Like%", "UPPER({Field}) LIKE UPPER({Value})"}, + {"%In", "{Field} IN ({Values})"}, + {"%Ids", "{Field} IN ({Values})"}, + {"%Between%", "{Field} BETWEEN {Value1} AND {Value2}"}, + {"%HasAll", "{Value} & {Field} = {Value}"}, + {"%HasAny", "{Value} & {Field} > 0"}, + + {"%IsNull", "{Field} IS NULL"}, + {"%IsNotNull", "{Field} IS NOT NULL"}, +}; +``` + +Each convention key includes `%` wildcards to define where a DataModel field names can appear, either as a Prefix, Suffix or both. The convention value describes the SQL filter that gets applied to the query when the property is populated. + +Properties that matches a DataModel field performs an exact query `{Field} = {Value}`, e.g: + +```typescript +const api = client.api(new QueryBookings({ id:1 })) +``` + +As `MinCost` matches the `"Min%"` convention it applies the `Cost >= 100` filter to the query: + +```typescript +const api = client.api(new QueryBookings({ minCost:100 })) +``` + +As `CostBetween` matches the `"%Between%"` convention it applies the `Cost BETWEEN 100 AND 200` filter to the query: + +```typescript +const api = client.api(new QueryBookings({ costBetween:[100,200] })) +``` + +AutoQuery also matches on pluralized fields where `Ids` matches `Id` and applies the `Id IN (1,2,3)` filter: + +```typescript +const api = client.api(new QueryBookings({ ids:[1,2,3] })) +``` + +Multiple Request DTO properties applies multiple **AND** filters, e.g: + +```typescript +const api = client.api(new QueryBookings({ minCost:100, ids:[1,2,3] })) +``` + +Applies the `(Cost >= 100) AND (Id IN (1,2,3))` filter. + +## Key Conventions + +### API Client Usage + +Frontend code imports from `lib/gateway.ts`: + +```typescript +import { client } from '@/lib/gateway' +import { QueryBookings } from '@/lib/dtos' + +const response = await client.api(new QueryBookings()) +``` + +The `client` is a configured `JsonServiceClient` pointing to `/api` (proxied to .NET backend). + +All .NET APIs are accessible by Request DTOs which implement either a `IReturn` a `IReturnVoid` interface which defines the API Response, e.g: + +```typescript +export class Hello implements IReturn, IGet +{ + public name: string; + public constructor(init?: Partial) { (Object as any).assign(this, init); } +} +export class HelloResponse +{ + public result: string; + public constructor(init?: Partial) { (Object as any).assign(this, init); } +} +``` + +### ServiceStack API Client Pattern + +Inside a React Component use `useClient()` to resolve a Service Client. The `ApiResult` can hold **loading**, **failed** and **successful** API Response states, e.g: + +```typescript +type Props = { value: string } +export default ({ value }:Props) => { + const [name, setName] = useState(value) + const client = useClient() + const [api, setApi] = useState>(new ApiResult()) + + useEffect(() => { + (async () => { + setApi(new ApiResult()) + setApi(await client.api(new Hello({ name }))) + })() + }, [name]) + + return (
+ + {api.error + ?
{api.error.message}
+ : api.succeeded + ?
{api.response.result}
+ :
loading...
} +
) +} +``` + +All client `api`, `apiVoid` and `apiForm` methods **never throws exceptions** - it always returns an `ApiResult` which contains either a **response** for successful responses or an **error** with a populated `ResponseStatus`, as such using `try/catch` around `client.api*` calls is always wrong as it implies it would throw an Exception, when it never does. + +The examples below show typical usage: + +The `api` and `apiVoid` APIs return an `ApiResult` which holds both successful and failed API Responses: + +```typescript +const api = await client.api(new Hello({ name })) +if (api.succeeded) { + console.log(`The API succeeded:`, api.response) +} else if (api.error) { + console.log(`The API failed:`, api.error) +} +``` + +The `apiForm` API can use a HTML Form's FormData for its Request Body together with an APIs **empty Request DTO**, e.g: + +```typescript +const submit = async (e: React.FormEvent) => { + const form = e.currentTarget as HTMLFormElement + const api = await client.apiForm(new CreateContact(), new FormData(form)) + if (api.succeeded) { + console.log(`The API succeeded:`, api.response) + } else if (api.error) { + console.log(`The API failed:`, api.error) + } +} +``` + +Using `apiForm` is required for multipart/form-data File Uploads. + +### ServiceStack Form Components + +The `@servicestack/react` [component library](https://react.servicestack.net) have several components to simplify UI generation. + +The `` Component can be used to render an API validation bound form for **any Request DTO**. + +```jsx +import { AutoForm, AutoCreateForm, AutoEditForm, HtmlFormat } from '@servicestack/react' + +function GenericFormExample() { + const [results, setResults] = useState() + + const onSuccess = (response:QueryResponse) => { + setResults(response.results) + } + + return ( + + {results && } + ) +} +``` + +The `` can be used with an AutoQuery CRUD `ICreateDb` DTO to render a create entity form. + +```jsx + +``` + +The `` can be used with an AutoQuery CRUD `IPatchDb` or `IUpdateDb` DTO to render an update form. +The `deleteType` can be set to use an `IDeleteDb` DTO to enable delete functionality. +```jsx +function EditFormExample({ booking }:{ Booking:booking }) { + return ( + ) +} +``` + +### ApiStateContext and Input Components + +The `ApiStateContext` can be used to inject an APIs Error `ResponseStatus` down to all `@servicestack/react` Input components. + +```jsx +import {ErrorSummary, TextInput, PrimaryButton, useClient, ApiStateContext} from "@servicestack/react" + +function CustomFormExample() { + const client = useClient() + const [userName, setUserName] = useState() + const [password, setPassword] = useState() + + const onSubmit = async (e:SyntheticEvent) => { + e.preventDefault() + const api = await client.api(new Authenticate({ provider: 'credentials', userName, password })) + if (api.succeeded) { + console.log('Signed In!', api.response) + } else if (api.error.errorCode === 'Unauthorized') { + console.log('Sign In failed:', api.error.message) + } + } + + return ( +
+ +
+ + + Log in +
+ +
) +} +``` + +All field errors are displayed next to their Input component all other API errors are displayed with the `` component. Use `except` to avoid displaying field errors which are already displayed next to their associated input component. + +If needed, the error ResponseStatus can be passed to components using its `status` property. + +### Routing + +- `/api/*` → ServiceStack services +- `/Identity/*` → ASP.NET Core Identity Razor Pages +- `/ui/*` → ServiceStack API Explorer +- `/admin-ui/*` → ServiceStack Admin UI (requires Admin role) +- `/types/typescript` → ServiceStack .NET API TypeScript DTOs (for dtos.ts) +- All other routes → React SPA (via fallback in dev/prod) + +### Razor Pages Integration + +The template includes Razor Pages for Identity UI (`/Identity` routes) that coexist with the React SPA. These use Tailwind CSS compiled from `MyApp/tailwind.input.css` to `MyApp/wwwroot/css/app.css`. + +### Environment Variables + +- `KAMAL_DEPLOY_HOST` - Production hostname for deployment + +### Background Jobs + +Configured in [Configure.BackgroundJobs.cs](MyApp/Configure.BackgroundJobs.cs) using `BackgroundsJobFeature`. Jobs are commands that implement `IAsyncCommand`. + +## Development Workflow + +1. **Start dev servers:** `dotnet watch` (starts both .NET and Vite) +2. **Make backend changes:** Edit C# files in `MyApp.ServiceModel` or `MyApp.ServiceInterface` +3. **Restart .NET Server** +4. **Regenerate DTOs:** `cd MyApp.Client && npm run dtos` +5. **Make frontend changes:** Edit React files in `MyApp.Client/src` +6. **Add new CRUD feature:** + - `npx okai init Feature` + - Edit `MyApp.ServiceModel/Feature.d.ts` + - `npx okai Feature.d.ts` + - `npm run migrate` + +Docs: [AutoQuery Dev Workflow](https://react-templates.net/docs/autoquery/dev-workflow) + +## Admin Features + +- `/admin-ui` - ServiceStack Admin UI (database, users, API explorer) +- `/admin-ui/users` - User management (requires Admin role) +- `/up` - Health check endpoint + +## Deployment + +GitHub Actions workflows in `.github/workflows/` uses [Kamal for Deployments](https://react-templates.net/docs/deployments): +- `build.yml` - CI build and test +- `build-container.yml` - Docker image build +- `release.yml` - Kamal deployment to production + +Configure `KAMAL_DEPLOY_HOST` in GitHub secrets for your hostname. Kamal config in `config/deploy.yml` derives service names from repository name. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/MyApp.Client/package.json b/MyApp.Client/package.json index 6a03303..a2da7ab 100644 --- a/MyApp.Client/package.json +++ b/MyApp.Client/package.json @@ -12,31 +12,31 @@ "postinstall": "node ./postinstall.mjs" }, "dependencies": { - "@servicestack/react": "^2.0.15", + "@servicestack/react": "^2.0.17", "@tailwindcss/forms": "^0.5.10", "clsx": "^2.1.1", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-net-templates": "^0.0.1", - "react-router-dom": "^7.9.5", + "react-router-dom": "^7.10.1", "tailwind-merge": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/vite": "^4.1.17", - "@types/node": "^24.10.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^5.1.0", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.0.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "postcss": "^8.5.6", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", - "vite": "^7.2.2" + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7" } } diff --git a/MyApp.Client/public/lib/jquery.validate.js b/MyApp.Client/public/lib/jquery.validate.js new file mode 100644 index 0000000..12674b0 --- /dev/null +++ b/MyApp.Client/public/lib/jquery.validate.js @@ -0,0 +1,1601 @@ +/*! + * jQuery Validation Plugin v1.17.0 + * + * https://jqueryvalidation.org/ + * + * Copyright (c) 2017 Jörn Zaefferer + * Released under the MIT license + */ +(function( factory ) { + if ( typeof define === "function" && define.amd ) { + define( ["jquery"], factory ); + } else if (typeof module === "object" && module.exports) { + module.exports = factory( require( "jquery" ) ); + } else { + factory( jQuery ); + } +}(function( $ ) { + +$.extend( $.fn, { + + // https://jqueryvalidation.org/validate/ + validate: function( options ) { + + // If nothing is selected, return nothing; can't chain anyway + if ( !this.length ) { + if ( options && options.debug && window.console ) { + console.warn( "Nothing selected, can't validate, returning nothing." ); + } + return; + } + + // Check if a validator for this form was already created + var validator = $.data( this[ 0 ], "validator" ); + if ( validator ) { + return validator; + } + + // Add novalidate tag if HTML5. + this.attr( "novalidate", "novalidate" ); + + validator = new $.validator( options, this[ 0 ] ); + $.data( this[ 0 ], "validator", validator ); + + if ( validator.settings.onsubmit ) { + + this.on( "click.validate", ":submit", function( event ) { + + // Track the used submit button to properly handle scripted + // submits later. + validator.submitButton = event.currentTarget; + + // Allow suppressing validation by adding a cancel class to the submit button + if ( $( this ).hasClass( "cancel" ) ) { + validator.cancelSubmit = true; + } + + // Allow suppressing validation by adding the html5 formnovalidate attribute to the submit button + if ( $( this ).attr( "formnovalidate" ) !== undefined ) { + validator.cancelSubmit = true; + } + } ); + + // Validate the form on submit + this.on( "submit.validate", function( event ) { + if ( validator.settings.debug ) { + + // Prevent form submit to be able to see console output + event.preventDefault(); + } + function handle() { + var hidden, result; + + // Insert a hidden input as a replacement for the missing submit button + // The hidden input is inserted in two cases: + // - A user defined a `submitHandler` + // - There was a pending request due to `remote` method and `stopRequest()` + // was called to submit the form in case it's valid + if ( validator.submitButton && ( validator.settings.submitHandler || validator.formSubmitted ) ) { + hidden = $( "" ) + .attr( "name", validator.submitButton.name ) + .val( $( validator.submitButton ).val() ) + .appendTo( validator.currentForm ); + } + + if ( validator.settings.submitHandler ) { + result = validator.settings.submitHandler.call( validator, validator.currentForm, event ); + if ( hidden ) { + + // And clean up afterwards; thanks to no-block-scope, hidden can be referenced + hidden.remove(); + } + if ( result !== undefined ) { + return result; + } + return false; + } + return true; + } + + // Prevent submit for invalid forms or custom submit handlers + if ( validator.cancelSubmit ) { + validator.cancelSubmit = false; + return handle(); + } + if ( validator.form() ) { + if ( validator.pendingRequest ) { + validator.formSubmitted = true; + return false; + } + return handle(); + } else { + validator.focusInvalid(); + return false; + } + } ); + } + + return validator; + }, + + // https://jqueryvalidation.org/valid/ + valid: function() { + var valid, validator, errorList; + + if ( $( this[ 0 ] ).is( "form" ) ) { + valid = this.validate().form(); + } else { + errorList = []; + valid = true; + validator = $( this[ 0 ].form ).validate(); + this.each( function() { + valid = validator.element( this ) && valid; + if ( !valid ) { + errorList = errorList.concat( validator.errorList ); + } + } ); + validator.errorList = errorList; + } + return valid; + }, + + // https://jqueryvalidation.org/rules/ + rules: function( command, argument ) { + var element = this[ 0 ], + settings, staticRules, existingRules, data, param, filtered; + + // If nothing is selected, return empty object; can't chain anyway + if ( element == null ) { + return; + } + + if ( !element.form && element.hasAttribute( "contenteditable" ) ) { + element.form = this.closest( "form" )[ 0 ]; + element.name = this.attr( "name" ); + } + + if ( element.form == null ) { + return; + } + + if ( command ) { + settings = $.data( element.form, "validator" ).settings; + staticRules = settings.rules; + existingRules = $.validator.staticRules( element ); + switch ( command ) { + case "add": + $.extend( existingRules, $.validator.normalizeRule( argument ) ); + + // Remove messages from rules, but allow them to be set separately + delete existingRules.messages; + staticRules[ element.name ] = existingRules; + if ( argument.messages ) { + settings.messages[ element.name ] = $.extend( settings.messages[ element.name ], argument.messages ); + } + break; + case "remove": + if ( !argument ) { + delete staticRules[ element.name ]; + return existingRules; + } + filtered = {}; + $.each( argument.split( /\s/ ), function( index, method ) { + filtered[ method ] = existingRules[ method ]; + delete existingRules[ method ]; + } ); + return filtered; + } + } + + data = $.validator.normalizeRules( + $.extend( + {}, + $.validator.classRules( element ), + $.validator.attributeRules( element ), + $.validator.dataRules( element ), + $.validator.staticRules( element ) + ), element ); + + // Make sure required is at front + if ( data.required ) { + param = data.required; + delete data.required; + data = $.extend( { required: param }, data ); + } + + // Make sure remote is at back + if ( data.remote ) { + param = data.remote; + delete data.remote; + data = $.extend( data, { remote: param } ); + } + + return data; + } +} ); + +// Custom selectors +$.extend( $.expr.pseudos || $.expr[ ":" ], { // '|| $.expr[ ":" ]' here enables backwards compatibility to jQuery 1.7. Can be removed when dropping jQ 1.7.x support + + // https://jqueryvalidation.org/blank-selector/ + blank: function( a ) { + return !$.trim( "" + $( a ).val() ); + }, + + // https://jqueryvalidation.org/filled-selector/ + filled: function( a ) { + var val = $( a ).val(); + return val !== null && !!$.trim( "" + val ); + }, + + // https://jqueryvalidation.org/unchecked-selector/ + unchecked: function( a ) { + return !$( a ).prop( "checked" ); + } +} ); + +// Constructor for validator +$.validator = function( options, form ) { + this.settings = $.extend( true, {}, $.validator.defaults, options ); + this.currentForm = form; + this.init(); +}; + +// https://jqueryvalidation.org/jQuery.validator.format/ +$.validator.format = function( source, params ) { + if ( arguments.length === 1 ) { + return function() { + var args = $.makeArray( arguments ); + args.unshift( source ); + return $.validator.format.apply( this, args ); + }; + } + if ( params === undefined ) { + return source; + } + if ( arguments.length > 2 && params.constructor !== Array ) { + params = $.makeArray( arguments ).slice( 1 ); + } + if ( params.constructor !== Array ) { + params = [ params ]; + } + $.each( params, function( i, n ) { + source = source.replace( new RegExp( "\\{" + i + "\\}", "g" ), function() { + return n; + } ); + } ); + return source; +}; + +$.extend( $.validator, { + + defaults: { + messages: {}, + groups: {}, + rules: {}, + errorClass: "error", + pendingClass: "pending", + validClass: "valid", + errorElement: "label", + focusCleanup: false, + focusInvalid: true, + errorContainer: $( [] ), + errorLabelContainer: $( [] ), + onsubmit: true, + ignore: ":hidden", + ignoreTitle: false, + onfocusin: function( element ) { + this.lastActive = element; + + // Hide error label and remove error class on focus if enabled + if ( this.settings.focusCleanup ) { + if ( this.settings.unhighlight ) { + this.settings.unhighlight.call( this, element, this.settings.errorClass, this.settings.validClass ); + } + this.hideThese( this.errorsFor( element ) ); + } + }, + onfocusout: function( element ) { + if ( !this.checkable( element ) && ( element.name in this.submitted || !this.optional( element ) ) ) { + this.element( element ); + } + }, + onkeyup: function( element, event ) { + + // Avoid revalidate the field when pressing one of the following keys + // Shift => 16 + // Ctrl => 17 + // Alt => 18 + // Caps lock => 20 + // End => 35 + // Home => 36 + // Left arrow => 37 + // Up arrow => 38 + // Right arrow => 39 + // Down arrow => 40 + // Insert => 45 + // Num lock => 144 + // AltGr key => 225 + var excludedKeys = [ + 16, 17, 18, 20, 35, 36, 37, + 38, 39, 40, 45, 144, 225 + ]; + + if ( event.which === 9 && this.elementValue( element ) === "" || $.inArray( event.keyCode, excludedKeys ) !== -1 ) { + return; + } else if ( element.name in this.submitted || element.name in this.invalid ) { + this.element( element ); + } + }, + onclick: function( element ) { + + // Click on selects, radiobuttons and checkboxes + if ( element.name in this.submitted ) { + this.element( element ); + + // Or option elements, check parent select in that case + } else if ( element.parentNode.name in this.submitted ) { + this.element( element.parentNode ); + } + }, + highlight: function( element, errorClass, validClass ) { + if ( element.type === "radio" ) { + this.findByName( element.name ).addClass( errorClass ).removeClass( validClass ); + } else { + $( element ).addClass( errorClass ).removeClass( validClass ); + } + }, + unhighlight: function( element, errorClass, validClass ) { + if ( element.type === "radio" ) { + this.findByName( element.name ).removeClass( errorClass ).addClass( validClass ); + } else { + $( element ).removeClass( errorClass ).addClass( validClass ); + } + } + }, + + // https://jqueryvalidation.org/jQuery.validator.setDefaults/ + setDefaults: function( settings ) { + $.extend( $.validator.defaults, settings ); + }, + + messages: { + required: "This field is required.", + remote: "Please fix this field.", + email: "Please enter a valid email address.", + url: "Please enter a valid URL.", + date: "Please enter a valid date.", + dateISO: "Please enter a valid date (ISO).", + number: "Please enter a valid number.", + digits: "Please enter only digits.", + equalTo: "Please enter the same value again.", + maxlength: $.validator.format( "Please enter no more than {0} characters." ), + minlength: $.validator.format( "Please enter at least {0} characters." ), + rangelength: $.validator.format( "Please enter a value between {0} and {1} characters long." ), + range: $.validator.format( "Please enter a value between {0} and {1}." ), + max: $.validator.format( "Please enter a value less than or equal to {0}." ), + min: $.validator.format( "Please enter a value greater than or equal to {0}." ), + step: $.validator.format( "Please enter a multiple of {0}." ) + }, + + autoCreateRanges: false, + + prototype: { + + init: function() { + this.labelContainer = $( this.settings.errorLabelContainer ); + this.errorContext = this.labelContainer.length && this.labelContainer || $( this.currentForm ); + this.containers = $( this.settings.errorContainer ).add( this.settings.errorLabelContainer ); + this.submitted = {}; + this.valueCache = {}; + this.pendingRequest = 0; + this.pending = {}; + this.invalid = {}; + this.reset(); + + var groups = ( this.groups = {} ), + rules; + $.each( this.settings.groups, function( key, value ) { + if ( typeof value === "string" ) { + value = value.split( /\s/ ); + } + $.each( value, function( index, name ) { + groups[ name ] = key; + } ); + } ); + rules = this.settings.rules; + $.each( rules, function( key, value ) { + rules[ key ] = $.validator.normalizeRule( value ); + } ); + + function delegate( event ) { + + // Set form expando on contenteditable + if ( !this.form && this.hasAttribute( "contenteditable" ) ) { + this.form = $( this ).closest( "form" )[ 0 ]; + this.name = $( this ).attr( "name" ); + } + + var validator = $.data( this.form, "validator" ), + eventType = "on" + event.type.replace( /^validate/, "" ), + settings = validator.settings; + if ( settings[ eventType ] && !$( this ).is( settings.ignore ) ) { + settings[ eventType ].call( validator, this, event ); + } + } + + $( this.currentForm ) + .on( "focusin.validate focusout.validate keyup.validate", + ":text, [type='password'], [type='file'], select, textarea, [type='number'], [type='search'], " + + "[type='tel'], [type='url'], [type='email'], [type='datetime'], [type='date'], [type='month'], " + + "[type='week'], [type='time'], [type='datetime-local'], [type='range'], [type='color'], " + + "[type='radio'], [type='checkbox'], [contenteditable], [type='button']", delegate ) + + // Support: Chrome, oldIE + // "select" is provided as event.target when clicking a option + .on( "click.validate", "select, option, [type='radio'], [type='checkbox']", delegate ); + + if ( this.settings.invalidHandler ) { + $( this.currentForm ).on( "invalid-form.validate", this.settings.invalidHandler ); + } + }, + + // https://jqueryvalidation.org/Validator.form/ + form: function() { + this.checkForm(); + $.extend( this.submitted, this.errorMap ); + this.invalid = $.extend( {}, this.errorMap ); + if ( !this.valid() ) { + $( this.currentForm ).triggerHandler( "invalid-form", [ this ] ); + } + this.showErrors(); + return this.valid(); + }, + + checkForm: function() { + this.prepareForm(); + for ( var i = 0, elements = ( this.currentElements = this.elements() ); elements[ i ]; i++ ) { + this.check( elements[ i ] ); + } + return this.valid(); + }, + + // https://jqueryvalidation.org/Validator.element/ + element: function( element ) { + var cleanElement = this.clean( element ), + checkElement = this.validationTargetFor( cleanElement ), + v = this, + result = true, + rs, group; + + if ( checkElement === undefined ) { + delete this.invalid[ cleanElement.name ]; + } else { + this.prepareElement( checkElement ); + this.currentElements = $( checkElement ); + + // If this element is grouped, then validate all group elements already + // containing a value + group = this.groups[ checkElement.name ]; + if ( group ) { + $.each( this.groups, function( name, testgroup ) { + if ( testgroup === group && name !== checkElement.name ) { + cleanElement = v.validationTargetFor( v.clean( v.findByName( name ) ) ); + if ( cleanElement && cleanElement.name in v.invalid ) { + v.currentElements.push( cleanElement ); + result = v.check( cleanElement ) && result; + } + } + } ); + } + + rs = this.check( checkElement ) !== false; + result = result && rs; + if ( rs ) { + this.invalid[ checkElement.name ] = false; + } else { + this.invalid[ checkElement.name ] = true; + } + + if ( !this.numberOfInvalids() ) { + + // Hide error containers on last error + this.toHide = this.toHide.add( this.containers ); + } + this.showErrors(); + + // Add aria-invalid status for screen readers + $( element ).attr( "aria-invalid", !rs ); + } + + return result; + }, + + // https://jqueryvalidation.org/Validator.showErrors/ + showErrors: function( errors ) { + if ( errors ) { + var validator = this; + + // Add items to error list and map + $.extend( this.errorMap, errors ); + this.errorList = $.map( this.errorMap, function( message, name ) { + return { + message: message, + element: validator.findByName( name )[ 0 ] + }; + } ); + + // Remove items from success list + this.successList = $.grep( this.successList, function( element ) { + return !( element.name in errors ); + } ); + } + if ( this.settings.showErrors ) { + this.settings.showErrors.call( this, this.errorMap, this.errorList ); + } else { + this.defaultShowErrors(); + } + }, + + // https://jqueryvalidation.org/Validator.resetForm/ + resetForm: function() { + if ( $.fn.resetForm ) { + $( this.currentForm ).resetForm(); + } + this.invalid = {}; + this.submitted = {}; + this.prepareForm(); + this.hideErrors(); + var elements = this.elements() + .removeData( "previousValue" ) + .removeAttr( "aria-invalid" ); + + this.resetElements( elements ); + }, + + resetElements: function( elements ) { + var i; + + if ( this.settings.unhighlight ) { + for ( i = 0; elements[ i ]; i++ ) { + this.settings.unhighlight.call( this, elements[ i ], + this.settings.errorClass, "" ); + this.findByName( elements[ i ].name ).removeClass( this.settings.validClass ); + } + } else { + elements + .removeClass( this.settings.errorClass ) + .removeClass( this.settings.validClass ); + } + }, + + numberOfInvalids: function() { + return this.objectLength( this.invalid ); + }, + + objectLength: function( obj ) { + /* jshint unused: false */ + var count = 0, + i; + for ( i in obj ) { + + // This check allows counting elements with empty error + // message as invalid elements + if ( obj[ i ] !== undefined && obj[ i ] !== null && obj[ i ] !== false ) { + count++; + } + } + return count; + }, + + hideErrors: function() { + this.hideThese( this.toHide ); + }, + + hideThese: function( errors ) { + errors.not( this.containers ).text( "" ); + this.addWrapper( errors ).hide(); + }, + + valid: function() { + return this.size() === 0; + }, + + size: function() { + return this.errorList.length; + }, + + focusInvalid: function() { + if ( this.settings.focusInvalid ) { + try { + $( this.findLastActive() || this.errorList.length && this.errorList[ 0 ].element || [] ) + .filter( ":visible" ) + .focus() + + // Manually trigger focusin event; without it, focusin handler isn't called, findLastActive won't have anything to find + .trigger( "focusin" ); + } catch ( e ) { + + // Ignore IE throwing errors when focusing hidden elements + } + } + }, + + findLastActive: function() { + var lastActive = this.lastActive; + return lastActive && $.grep( this.errorList, function( n ) { + return n.element.name === lastActive.name; + } ).length === 1 && lastActive; + }, + + elements: function() { + var validator = this, + rulesCache = {}; + + // Select all valid inputs inside the form (no submit or reset buttons) + return $( this.currentForm ) + .find( "input, select, textarea, [contenteditable]" ) + .not( ":submit, :reset, :image, :disabled" ) + .not( this.settings.ignore ) + .filter( function() { + var name = this.name || $( this ).attr( "name" ); // For contenteditable + if ( !name && validator.settings.debug && window.console ) { + console.error( "%o has no name assigned", this ); + } + + // Set form expando on contenteditable + if ( this.hasAttribute( "contenteditable" ) ) { + this.form = $( this ).closest( "form" )[ 0 ]; + this.name = name; + } + + // Select only the first element for each name, and only those with rules specified + if ( name in rulesCache || !validator.objectLength( $( this ).rules() ) ) { + return false; + } + + rulesCache[ name ] = true; + return true; + } ); + }, + + clean: function( selector ) { + return $( selector )[ 0 ]; + }, + + errors: function() { + var errorClass = this.settings.errorClass.split( " " ).join( "." ); + return $( this.settings.errorElement + "." + errorClass, this.errorContext ); + }, + + resetInternals: function() { + this.successList = []; + this.errorList = []; + this.errorMap = {}; + this.toShow = $( [] ); + this.toHide = $( [] ); + }, + + reset: function() { + this.resetInternals(); + this.currentElements = $( [] ); + }, + + prepareForm: function() { + this.reset(); + this.toHide = this.errors().add( this.containers ); + }, + + prepareElement: function( element ) { + this.reset(); + this.toHide = this.errorsFor( element ); + }, + + elementValue: function( element ) { + var $element = $( element ), + type = element.type, + val, idx; + + if ( type === "radio" || type === "checkbox" ) { + return this.findByName( element.name ).filter( ":checked" ).val(); + } else if ( type === "number" && typeof element.validity !== "undefined" ) { + return element.validity.badInput ? "NaN" : $element.val(); + } + + if ( element.hasAttribute( "contenteditable" ) ) { + val = $element.text(); + } else { + val = $element.val(); + } + + if ( type === "file" ) { + + // Modern browser (chrome & safari) + if ( val.substr( 0, 12 ) === "C:\\fakepath\\" ) { + return val.substr( 12 ); + } + + // Legacy browsers + // Unix-based path + idx = val.lastIndexOf( "/" ); + if ( idx >= 0 ) { + return val.substr( idx + 1 ); + } + + // Windows-based path + idx = val.lastIndexOf( "\\" ); + if ( idx >= 0 ) { + return val.substr( idx + 1 ); + } + + // Just the file name + return val; + } + + if ( typeof val === "string" ) { + return val.replace( /\r/g, "" ); + } + return val; + }, + + check: function( element ) { + element = this.validationTargetFor( this.clean( element ) ); + + var rules = $( element ).rules(), + rulesCount = $.map( rules, function( n, i ) { + return i; + } ).length, + dependencyMismatch = false, + val = this.elementValue( element ), + result, method, rule, normalizer; + + // Prioritize the local normalizer defined for this element over the global one + // if the former exists, otherwise user the global one in case it exists. + if ( typeof rules.normalizer === "function" ) { + normalizer = rules.normalizer; + } else if ( typeof this.settings.normalizer === "function" ) { + normalizer = this.settings.normalizer; + } + + // If normalizer is defined, then call it to retreive the changed value instead + // of using the real one. + // Note that `this` in the normalizer is `element`. + if ( normalizer ) { + val = normalizer.call( element, val ); + + if ( typeof val !== "string" ) { + throw new TypeError( "The normalizer should return a string value." ); + } + + // Delete the normalizer from rules to avoid treating it as a pre-defined method. + delete rules.normalizer; + } + + for ( method in rules ) { + rule = { method: method, parameters: rules[ method ] }; + try { + result = $.validator.methods[ method ].call( this, val, element, rule.parameters ); + + // If a method indicates that the field is optional and therefore valid, + // don't mark it as valid when there are no other rules + if ( result === "dependency-mismatch" && rulesCount === 1 ) { + dependencyMismatch = true; + continue; + } + dependencyMismatch = false; + + if ( result === "pending" ) { + this.toHide = this.toHide.not( this.errorsFor( element ) ); + return; + } + + if ( !result ) { + this.formatAndAdd( element, rule ); + return false; + } + } catch ( e ) { + if ( this.settings.debug && window.console ) { + console.log( "Exception occurred when checking element " + element.id + ", check the '" + rule.method + "' method.", e ); + } + if ( e instanceof TypeError ) { + e.message += ". Exception occurred when checking element " + element.id + ", check the '" + rule.method + "' method."; + } + + throw e; + } + } + if ( dependencyMismatch ) { + return; + } + if ( this.objectLength( rules ) ) { + this.successList.push( element ); + } + return true; + }, + + // Return the custom message for the given element and validation method + // specified in the element's HTML5 data attribute + // return the generic message if present and no method specific message is present + customDataMessage: function( element, method ) { + return $( element ).data( "msg" + method.charAt( 0 ).toUpperCase() + + method.substring( 1 ).toLowerCase() ) || $( element ).data( "msg" ); + }, + + // Return the custom message for the given element name and validation method + customMessage: function( name, method ) { + var m = this.settings.messages[ name ]; + return m && ( m.constructor === String ? m : m[ method ] ); + }, + + // Return the first defined argument, allowing empty strings + findDefined: function() { + for ( var i = 0; i < arguments.length; i++ ) { + if ( arguments[ i ] !== undefined ) { + return arguments[ i ]; + } + } + return undefined; + }, + + // The second parameter 'rule' used to be a string, and extended to an object literal + // of the following form: + // rule = { + // method: "method name", + // parameters: "the given method parameters" + // } + // + // The old behavior still supported, kept to maintain backward compatibility with + // old code, and will be removed in the next major release. + defaultMessage: function( element, rule ) { + if ( typeof rule === "string" ) { + rule = { method: rule }; + } + + var message = this.findDefined( + this.customMessage( element.name, rule.method ), + this.customDataMessage( element, rule.method ), + + // 'title' is never undefined, so handle empty string as undefined + !this.settings.ignoreTitle && element.title || undefined, + $.validator.messages[ rule.method ], + "Warning: No message defined for " + element.name + "" + ), + theregex = /\$?\{(\d+)\}/g; + if ( typeof message === "function" ) { + message = message.call( this, rule.parameters, element ); + } else if ( theregex.test( message ) ) { + message = $.validator.format( message.replace( theregex, "{$1}" ), rule.parameters ); + } + + return message; + }, + + formatAndAdd: function( element, rule ) { + var message = this.defaultMessage( element, rule ); + + this.errorList.push( { + message: message, + element: element, + method: rule.method + } ); + + this.errorMap[ element.name ] = message; + this.submitted[ element.name ] = message; + }, + + addWrapper: function( toToggle ) { + if ( this.settings.wrapper ) { + toToggle = toToggle.add( toToggle.parent( this.settings.wrapper ) ); + } + return toToggle; + }, + + defaultShowErrors: function() { + var i, elements, error; + for ( i = 0; this.errorList[ i ]; i++ ) { + error = this.errorList[ i ]; + if ( this.settings.highlight ) { + this.settings.highlight.call( this, error.element, this.settings.errorClass, this.settings.validClass ); + } + this.showLabel( error.element, error.message ); + } + if ( this.errorList.length ) { + this.toShow = this.toShow.add( this.containers ); + } + if ( this.settings.success ) { + for ( i = 0; this.successList[ i ]; i++ ) { + this.showLabel( this.successList[ i ] ); + } + } + if ( this.settings.unhighlight ) { + for ( i = 0, elements = this.validElements(); elements[ i ]; i++ ) { + this.settings.unhighlight.call( this, elements[ i ], this.settings.errorClass, this.settings.validClass ); + } + } + this.toHide = this.toHide.not( this.toShow ); + this.hideErrors(); + this.addWrapper( this.toShow ).show(); + }, + + validElements: function() { + return this.currentElements.not( this.invalidElements() ); + }, + + invalidElements: function() { + return $( this.errorList ).map( function() { + return this.element; + } ); + }, + + showLabel: function( element, message ) { + var place, group, errorID, v, + error = this.errorsFor( element ), + elementID = this.idOrName( element ), + describedBy = $( element ).attr( "aria-describedby" ); + + if ( error.length ) { + + // Refresh error/success class + error.removeClass( this.settings.validClass ).addClass( this.settings.errorClass ); + + // Replace message on existing label + error.html( message ); + } else { + + // Create error element + error = $( "<" + this.settings.errorElement + ">" ) + .attr( "id", elementID + "-error" ) + .addClass( this.settings.errorClass ) + .html( message || "" ); + + // Maintain reference to the element to be placed into the DOM + place = error; + if ( this.settings.wrapper ) { + + // Make sure the element is visible, even in IE + // actually showing the wrapped element is handled elsewhere + place = error.hide().show().wrap( "<" + this.settings.wrapper + "/>" ).parent(); + } + if ( this.labelContainer.length ) { + this.labelContainer.append( place ); + } else if ( this.settings.errorPlacement ) { + this.settings.errorPlacement.call( this, place, $( element ) ); + } else { + place.insertAfter( element ); + } + + // Link error back to the element + if ( error.is( "label" ) ) { + + // If the error is a label, then associate using 'for' + error.attr( "for", elementID ); + + // If the element is not a child of an associated label, then it's necessary + // to explicitly apply aria-describedby + } else if ( error.parents( "label[for='" + this.escapeCssMeta( elementID ) + "']" ).length === 0 ) { + errorID = error.attr( "id" ); + + // Respect existing non-error aria-describedby + if ( !describedBy ) { + describedBy = errorID; + } else if ( !describedBy.match( new RegExp( "\\b" + this.escapeCssMeta( errorID ) + "\\b" ) ) ) { + + // Add to end of list if not already present + describedBy += " " + errorID; + } + $( element ).attr( "aria-describedby", describedBy ); + + // If this element is grouped, then assign to all elements in the same group + group = this.groups[ element.name ]; + if ( group ) { + v = this; + $.each( v.groups, function( name, testgroup ) { + if ( testgroup === group ) { + $( "[name='" + v.escapeCssMeta( name ) + "']", v.currentForm ) + .attr( "aria-describedby", error.attr( "id" ) ); + } + } ); + } + } + } + if ( !message && this.settings.success ) { + error.text( "" ); + if ( typeof this.settings.success === "string" ) { + error.addClass( this.settings.success ); + } else { + this.settings.success( error, element ); + } + } + this.toShow = this.toShow.add( error ); + }, + + errorsFor: function( element ) { + var name = this.escapeCssMeta( this.idOrName( element ) ), + describer = $( element ).attr( "aria-describedby" ), + selector = "label[for='" + name + "'], label[for='" + name + "'] *"; + + // 'aria-describedby' should directly reference the error element + if ( describer ) { + selector = selector + ", #" + this.escapeCssMeta( describer ) + .replace( /\s+/g, ", #" ); + } + + return this + .errors() + .filter( selector ); + }, + + // See https://api.jquery.com/category/selectors/, for CSS + // meta-characters that should be escaped in order to be used with JQuery + // as a literal part of a name/id or any selector. + escapeCssMeta: function( string ) { + return string.replace( /([\\!"#$%&'()*+,./:;<=>?@\[\]^`{|}~])/g, "\\$1" ); + }, + + idOrName: function( element ) { + return this.groups[ element.name ] || ( this.checkable( element ) ? element.name : element.id || element.name ); + }, + + validationTargetFor: function( element ) { + + // If radio/checkbox, validate first element in group instead + if ( this.checkable( element ) ) { + element = this.findByName( element.name ); + } + + // Always apply ignore filter + return $( element ).not( this.settings.ignore )[ 0 ]; + }, + + checkable: function( element ) { + return ( /radio|checkbox/i ).test( element.type ); + }, + + findByName: function( name ) { + return $( this.currentForm ).find( "[name='" + this.escapeCssMeta( name ) + "']" ); + }, + + getLength: function( value, element ) { + switch ( element.nodeName.toLowerCase() ) { + case "select": + return $( "option:selected", element ).length; + case "input": + if ( this.checkable( element ) ) { + return this.findByName( element.name ).filter( ":checked" ).length; + } + } + return value.length; + }, + + depend: function( param, element ) { + return this.dependTypes[ typeof param ] ? this.dependTypes[ typeof param ]( param, element ) : true; + }, + + dependTypes: { + "boolean": function( param ) { + return param; + }, + "string": function( param, element ) { + return !!$( param, element.form ).length; + }, + "function": function( param, element ) { + return param( element ); + } + }, + + optional: function( element ) { + var val = this.elementValue( element ); + return !$.validator.methods.required.call( this, val, element ) && "dependency-mismatch"; + }, + + startRequest: function( element ) { + if ( !this.pending[ element.name ] ) { + this.pendingRequest++; + $( element ).addClass( this.settings.pendingClass ); + this.pending[ element.name ] = true; + } + }, + + stopRequest: function( element, valid ) { + this.pendingRequest--; + + // Sometimes synchronization fails, make sure pendingRequest is never < 0 + if ( this.pendingRequest < 0 ) { + this.pendingRequest = 0; + } + delete this.pending[ element.name ]; + $( element ).removeClass( this.settings.pendingClass ); + if ( valid && this.pendingRequest === 0 && this.formSubmitted && this.form() ) { + $( this.currentForm ).submit(); + + // Remove the hidden input that was used as a replacement for the + // missing submit button. The hidden input is added by `handle()` + // to ensure that the value of the used submit button is passed on + // for scripted submits triggered by this method + if ( this.submitButton ) { + $( "input:hidden[name='" + this.submitButton.name + "']", this.currentForm ).remove(); + } + + this.formSubmitted = false; + } else if ( !valid && this.pendingRequest === 0 && this.formSubmitted ) { + $( this.currentForm ).triggerHandler( "invalid-form", [ this ] ); + this.formSubmitted = false; + } + }, + + previousValue: function( element, method ) { + method = typeof method === "string" && method || "remote"; + + return $.data( element, "previousValue" ) || $.data( element, "previousValue", { + old: null, + valid: true, + message: this.defaultMessage( element, { method: method } ) + } ); + }, + + // Cleans up all forms and elements, removes validator-specific events + destroy: function() { + this.resetForm(); + + $( this.currentForm ) + .off( ".validate" ) + .removeData( "validator" ) + .find( ".validate-equalTo-blur" ) + .off( ".validate-equalTo" ) + .removeClass( "validate-equalTo-blur" ); + } + + }, + + classRuleSettings: { + required: { required: true }, + email: { email: true }, + url: { url: true }, + date: { date: true }, + dateISO: { dateISO: true }, + number: { number: true }, + digits: { digits: true }, + creditcard: { creditcard: true } + }, + + addClassRules: function( className, rules ) { + if ( className.constructor === String ) { + this.classRuleSettings[ className ] = rules; + } else { + $.extend( this.classRuleSettings, className ); + } + }, + + classRules: function( element ) { + var rules = {}, + classes = $( element ).attr( "class" ); + + if ( classes ) { + $.each( classes.split( " " ), function() { + if ( this in $.validator.classRuleSettings ) { + $.extend( rules, $.validator.classRuleSettings[ this ] ); + } + } ); + } + return rules; + }, + + normalizeAttributeRule: function( rules, type, method, value ) { + + // Convert the value to a number for number inputs, and for text for backwards compability + // allows type="date" and others to be compared as strings + if ( /min|max|step/.test( method ) && ( type === null || /number|range|text/.test( type ) ) ) { + value = Number( value ); + + // Support Opera Mini, which returns NaN for undefined minlength + if ( isNaN( value ) ) { + value = undefined; + } + } + + if ( value || value === 0 ) { + rules[ method ] = value; + } else if ( type === method && type !== "range" ) { + + // Exception: the jquery validate 'range' method + // does not test for the html5 'range' type + rules[ method ] = true; + } + }, + + attributeRules: function( element ) { + var rules = {}, + $element = $( element ), + type = element.getAttribute( "type" ), + method, value; + + for ( method in $.validator.methods ) { + + // Support for in both html5 and older browsers + if ( method === "required" ) { + value = element.getAttribute( method ); + + // Some browsers return an empty string for the required attribute + // and non-HTML5 browsers might have required="" markup + if ( value === "" ) { + value = true; + } + + // Force non-HTML5 browsers to return bool + value = !!value; + } else { + value = $element.attr( method ); + } + + this.normalizeAttributeRule( rules, type, method, value ); + } + + // 'maxlength' may be returned as -1, 2147483647 ( IE ) and 524288 ( safari ) for text inputs + if ( rules.maxlength && /-1|2147483647|524288/.test( rules.maxlength ) ) { + delete rules.maxlength; + } + + return rules; + }, + + dataRules: function( element ) { + var rules = {}, + $element = $( element ), + type = element.getAttribute( "type" ), + method, value; + + for ( method in $.validator.methods ) { + value = $element.data( "rule" + method.charAt( 0 ).toUpperCase() + method.substring( 1 ).toLowerCase() ); + this.normalizeAttributeRule( rules, type, method, value ); + } + return rules; + }, + + staticRules: function( element ) { + var rules = {}, + validator = $.data( element.form, "validator" ); + + if ( validator.settings.rules ) { + rules = $.validator.normalizeRule( validator.settings.rules[ element.name ] ) || {}; + } + return rules; + }, + + normalizeRules: function( rules, element ) { + + // Handle dependency check + $.each( rules, function( prop, val ) { + + // Ignore rule when param is explicitly false, eg. required:false + if ( val === false ) { + delete rules[ prop ]; + return; + } + if ( val.param || val.depends ) { + var keepRule = true; + switch ( typeof val.depends ) { + case "string": + keepRule = !!$( val.depends, element.form ).length; + break; + case "function": + keepRule = val.depends.call( element, element ); + break; + } + if ( keepRule ) { + rules[ prop ] = val.param !== undefined ? val.param : true; + } else { + $.data( element.form, "validator" ).resetElements( $( element ) ); + delete rules[ prop ]; + } + } + } ); + + // Evaluate parameters + $.each( rules, function( rule, parameter ) { + rules[ rule ] = $.isFunction( parameter ) && rule !== "normalizer" ? parameter( element ) : parameter; + } ); + + // Clean number parameters + $.each( [ "minlength", "maxlength" ], function() { + if ( rules[ this ] ) { + rules[ this ] = Number( rules[ this ] ); + } + } ); + $.each( [ "rangelength", "range" ], function() { + var parts; + if ( rules[ this ] ) { + if ( $.isArray( rules[ this ] ) ) { + rules[ this ] = [ Number( rules[ this ][ 0 ] ), Number( rules[ this ][ 1 ] ) ]; + } else if ( typeof rules[ this ] === "string" ) { + parts = rules[ this ].replace( /[\[\]]/g, "" ).split( /[\s,]+/ ); + rules[ this ] = [ Number( parts[ 0 ] ), Number( parts[ 1 ] ) ]; + } + } + } ); + + if ( $.validator.autoCreateRanges ) { + + // Auto-create ranges + if ( rules.min != null && rules.max != null ) { + rules.range = [ rules.min, rules.max ]; + delete rules.min; + delete rules.max; + } + if ( rules.minlength != null && rules.maxlength != null ) { + rules.rangelength = [ rules.minlength, rules.maxlength ]; + delete rules.minlength; + delete rules.maxlength; + } + } + + return rules; + }, + + // Converts a simple string to a {string: true} rule, e.g., "required" to {required:true} + normalizeRule: function( data ) { + if ( typeof data === "string" ) { + var transformed = {}; + $.each( data.split( /\s/ ), function() { + transformed[ this ] = true; + } ); + data = transformed; + } + return data; + }, + + // https://jqueryvalidation.org/jQuery.validator.addMethod/ + addMethod: function( name, method, message ) { + $.validator.methods[ name ] = method; + $.validator.messages[ name ] = message !== undefined ? message : $.validator.messages[ name ]; + if ( method.length < 3 ) { + $.validator.addClassRules( name, $.validator.normalizeRule( name ) ); + } + }, + + // https://jqueryvalidation.org/jQuery.validator.methods/ + methods: { + + // https://jqueryvalidation.org/required-method/ + required: function( value, element, param ) { + + // Check if dependency is met + if ( !this.depend( param, element ) ) { + return "dependency-mismatch"; + } + if ( element.nodeName.toLowerCase() === "select" ) { + + // Could be an array for select-multiple or a string, both are fine this way + var val = $( element ).val(); + return val && val.length > 0; + } + if ( this.checkable( element ) ) { + return this.getLength( value, element ) > 0; + } + return value.length > 0; + }, + + // https://jqueryvalidation.org/email-method/ + email: function( value, element ) { + + // From https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address + // Retrieved 2014-01-14 + // If you have a problem with this implementation, report a bug against the above spec + // Or use custom methods to implement your own email validation + return this.optional( element ) || /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( value ); + }, + + // https://jqueryvalidation.org/url-method/ + url: function( value, element ) { + + // Copyright (c) 2010-2013 Diego Perini, MIT licensed + // https://gist.github.com/dperini/729294 + // see also https://mathiasbynens.be/demo/url-regex + // modified to allow protocol-relative URLs + return this.optional( element ) || /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i.test( value ); + }, + + // https://jqueryvalidation.org/date-method/ + date: function( value, element ) { + return this.optional( element ) || !/Invalid|NaN/.test( new Date( value ).toString() ); + }, + + // https://jqueryvalidation.org/dateISO-method/ + dateISO: function( value, element ) { + return this.optional( element ) || /^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test( value ); + }, + + // https://jqueryvalidation.org/number-method/ + number: function( value, element ) { + return this.optional( element ) || /^(?:-?\d+|-?\d{1,3}(?:,\d{3})+)?(?:\.\d+)?$/.test( value ); + }, + + // https://jqueryvalidation.org/digits-method/ + digits: function( value, element ) { + return this.optional( element ) || /^\d+$/.test( value ); + }, + + // https://jqueryvalidation.org/minlength-method/ + minlength: function( value, element, param ) { + var length = $.isArray( value ) ? value.length : this.getLength( value, element ); + return this.optional( element ) || length >= param; + }, + + // https://jqueryvalidation.org/maxlength-method/ + maxlength: function( value, element, param ) { + var length = $.isArray( value ) ? value.length : this.getLength( value, element ); + return this.optional( element ) || length <= param; + }, + + // https://jqueryvalidation.org/rangelength-method/ + rangelength: function( value, element, param ) { + var length = $.isArray( value ) ? value.length : this.getLength( value, element ); + return this.optional( element ) || ( length >= param[ 0 ] && length <= param[ 1 ] ); + }, + + // https://jqueryvalidation.org/min-method/ + min: function( value, element, param ) { + return this.optional( element ) || value >= param; + }, + + // https://jqueryvalidation.org/max-method/ + max: function( value, element, param ) { + return this.optional( element ) || value <= param; + }, + + // https://jqueryvalidation.org/range-method/ + range: function( value, element, param ) { + return this.optional( element ) || ( value >= param[ 0 ] && value <= param[ 1 ] ); + }, + + // https://jqueryvalidation.org/step-method/ + step: function( value, element, param ) { + var type = $( element ).attr( "type" ), + errorMessage = "Step attribute on input type " + type + " is not supported.", + supportedTypes = [ "text", "number", "range" ], + re = new RegExp( "\\b" + type + "\\b" ), + notSupported = type && !re.test( supportedTypes.join() ), + decimalPlaces = function( num ) { + var match = ( "" + num ).match( /(?:\.(\d+))?$/ ); + if ( !match ) { + return 0; + } + + // Number of digits right of decimal point. + return match[ 1 ] ? match[ 1 ].length : 0; + }, + toInt = function( num ) { + return Math.round( num * Math.pow( 10, decimals ) ); + }, + valid = true, + decimals; + + // Works only for text, number and range input types + // TODO find a way to support input types date, datetime, datetime-local, month, time and week + if ( notSupported ) { + throw new Error( errorMessage ); + } + + decimals = decimalPlaces( param ); + + // Value can't have too many decimals + if ( decimalPlaces( value ) > decimals || toInt( value ) % toInt( param ) !== 0 ) { + valid = false; + } + + return this.optional( element ) || valid; + }, + + // https://jqueryvalidation.org/equalTo-method/ + equalTo: function( value, element, param ) { + + // Bind to the blur event of the target in order to revalidate whenever the target field is updated + var target = $( param ); + if ( this.settings.onfocusout && target.not( ".validate-equalTo-blur" ).length ) { + target.addClass( "validate-equalTo-blur" ).on( "blur.validate-equalTo", function() { + $( element ).valid(); + } ); + } + return value === target.val(); + }, + + // https://jqueryvalidation.org/remote-method/ + remote: function( value, element, param, method ) { + if ( this.optional( element ) ) { + return "dependency-mismatch"; + } + + method = typeof method === "string" && method || "remote"; + + var previous = this.previousValue( element, method ), + validator, data, optionDataString; + + if ( !this.settings.messages[ element.name ] ) { + this.settings.messages[ element.name ] = {}; + } + previous.originalMessage = previous.originalMessage || this.settings.messages[ element.name ][ method ]; + this.settings.messages[ element.name ][ method ] = previous.message; + + param = typeof param === "string" && { url: param } || param; + optionDataString = $.param( $.extend( { data: value }, param.data ) ); + if ( previous.old === optionDataString ) { + return previous.valid; + } + + previous.old = optionDataString; + validator = this; + this.startRequest( element ); + data = {}; + data[ element.name ] = value; + $.ajax( $.extend( true, { + mode: "abort", + port: "validate" + element.name, + dataType: "json", + data: data, + context: validator.currentForm, + success: function( response ) { + var valid = response === true || response === "true", + errors, message, submitted; + + validator.settings.messages[ element.name ][ method ] = previous.originalMessage; + if ( valid ) { + submitted = validator.formSubmitted; + validator.resetInternals(); + validator.toHide = validator.errorsFor( element ); + validator.formSubmitted = submitted; + validator.successList.push( element ); + validator.invalid[ element.name ] = false; + validator.showErrors(); + } else { + errors = {}; + message = response || validator.defaultMessage( element, { method: method, parameters: value } ); + errors[ element.name ] = previous.message = message; + validator.invalid[ element.name ] = true; + validator.showErrors( errors ); + } + previous.valid = valid; + validator.stopRequest( element, valid ); + } + }, param ) ); + return "pending"; + } + } + +} ); + +// Ajax mode: abort +// usage: $.ajax({ mode: "abort"[, port: "uniqueport"]}); +// if mode:"abort" is used, the previous request on that port (port can be undefined) is aborted via XMLHttpRequest.abort() + +var pendingRequests = {}, + ajax; + +// Use a prefilter if available (1.5+) +if ( $.ajaxPrefilter ) { + $.ajaxPrefilter( function( settings, _, xhr ) { + var port = settings.port; + if ( settings.mode === "abort" ) { + if ( pendingRequests[ port ] ) { + pendingRequests[ port ].abort(); + } + pendingRequests[ port ] = xhr; + } + } ); +} else { + + // Proxy ajax + ajax = $.ajax; + $.ajax = function( settings ) { + var mode = ( "mode" in settings ? settings : $.ajaxSettings ).mode, + port = ( "port" in settings ? settings : $.ajaxSettings ).port; + if ( mode === "abort" ) { + if ( pendingRequests[ port ] ) { + pendingRequests[ port ].abort(); + } + pendingRequests[ port ] = ajax.apply( this, arguments ); + return pendingRequests[ port ]; + } + return ajax.apply( this, arguments ); + }; +} +return $; +})); \ No newline at end of file diff --git a/MyApp.Client/public/lib/jquery.validate.unobtrusive.js b/MyApp.Client/public/lib/jquery.validate.unobtrusive.js new file mode 100644 index 0000000..73f5298 --- /dev/null +++ b/MyApp.Client/public/lib/jquery.validate.unobtrusive.js @@ -0,0 +1,432 @@ +// Unobtrusive validation support library for jQuery and jQuery Validate +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// @version v3.2.11 + +/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */ +/*global document: false, jQuery: false */ + +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define("jquery.validate.unobtrusive", ['jquery-validation'], factory); + } else if (typeof module === 'object' && module.exports) { + // CommonJS-like environments that support module.exports + module.exports = factory(require('jquery-validation')); + } else { + // Browser global + jQuery.validator.unobtrusive = factory(jQuery); + } +}(function ($) { + var $jQval = $.validator, + adapters, + data_validation = "unobtrusiveValidation"; + + function setValidationValues(options, ruleName, value) { + options.rules[ruleName] = value; + if (options.message) { + options.messages[ruleName] = options.message; + } + } + + function splitAndTrim(value) { + return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g); + } + + function escapeAttributeValue(value) { + // As mentioned on http://api.jquery.com/category/selectors/ + return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1"); + } + + function getModelPrefix(fieldName) { + return fieldName.substr(0, fieldName.lastIndexOf(".") + 1); + } + + function appendModelPrefix(value, prefix) { + if (value.indexOf("*.") === 0) { + value = value.replace("*.", prefix); + } + return value; + } + + function onError(error, inputElement) { // 'this' is the form element + var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"), + replaceAttrValue = container.attr("data-valmsg-replace"), + replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null; + + container.removeClass("field-validation-valid").addClass("field-validation-error"); + error.data("unobtrusiveContainer", container); + + if (replace) { + container.empty(); + error.removeClass("input-validation-error").appendTo(container); + } + else { + error.hide(); + } + } + + function onErrors(event, validator) { // 'this' is the form element + var container = $(this).find("[data-valmsg-summary=true]"), + list = container.find("ul"); + + if (list && list.length && validator.errorList.length) { + list.empty(); + container.addClass("validation-summary-errors").removeClass("validation-summary-valid"); + + $.each(validator.errorList, function () { + $("
  • ").html(this.message).appendTo(list); + }); + } + } + + function onSuccess(error) { // 'this' is the form element + var container = error.data("unobtrusiveContainer"); + + if (container) { + var replaceAttrValue = container.attr("data-valmsg-replace"), + replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null; + + container.addClass("field-validation-valid").removeClass("field-validation-error"); + error.removeData("unobtrusiveContainer"); + + if (replace) { + container.empty(); + } + } + } + + function onReset(event) { // 'this' is the form element + var $form = $(this), + key = '__jquery_unobtrusive_validation_form_reset'; + if ($form.data(key)) { + return; + } + // Set a flag that indicates we're currently resetting the form. + $form.data(key, true); + try { + $form.data("validator").resetForm(); + } finally { + $form.removeData(key); + } + + $form.find(".validation-summary-errors") + .addClass("validation-summary-valid") + .removeClass("validation-summary-errors"); + $form.find(".field-validation-error") + .addClass("field-validation-valid") + .removeClass("field-validation-error") + .removeData("unobtrusiveContainer") + .find(">*") // If we were using valmsg-replace, get the underlying error + .removeData("unobtrusiveContainer"); + } + + function validationInfo(form) { + var $form = $(form), + result = $form.data(data_validation), + onResetProxy = $.proxy(onReset, form), + defaultOptions = $jQval.unobtrusive.options || {}, + execInContext = function (name, args) { + var func = defaultOptions[name]; + func && $.isFunction(func) && func.apply(form, args); + }; + + if (!result) { + result = { + options: { // options structure passed to jQuery Validate's validate() method + errorClass: defaultOptions.errorClass || "input-validation-error", + errorElement: defaultOptions.errorElement || "span", + errorPlacement: function () { + onError.apply(form, arguments); + execInContext("errorPlacement", arguments); + }, + invalidHandler: function () { + onErrors.apply(form, arguments); + execInContext("invalidHandler", arguments); + }, + messages: {}, + rules: {}, + success: function () { + onSuccess.apply(form, arguments); + execInContext("success", arguments); + } + }, + attachValidation: function () { + $form + .off("reset." + data_validation, onResetProxy) + .on("reset." + data_validation, onResetProxy) + .validate(this.options); + }, + validate: function () { // a validation function that is called by unobtrusive Ajax + $form.validate(); + return $form.valid(); + } + }; + $form.data(data_validation, result); + } + + return result; + } + + $jQval.unobtrusive = { + adapters: [], + + parseElement: function (element, skipAttach) { + /// + /// Parses a single HTML element for unobtrusive validation attributes. + /// + /// The HTML element to be parsed. + /// [Optional] true to skip attaching the + /// validation to the form. If parsing just this single element, you should specify true. + /// If parsing several elements, you should specify false, and manually attach the validation + /// to the form when you are finished. The default is false. + var $element = $(element), + form = $element.parents("form")[0], + valInfo, rules, messages; + + if (!form) { // Cannot do client-side validation without a form + return; + } + + valInfo = validationInfo(form); + valInfo.options.rules[element.name] = rules = {}; + valInfo.options.messages[element.name] = messages = {}; + + $.each(this.adapters, function () { + var prefix = "data-val-" + this.name, + message = $element.attr(prefix), + paramValues = {}; + + if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy) + prefix += "-"; + + $.each(this.params, function () { + paramValues[this] = $element.attr(prefix + this); + }); + + this.adapt({ + element: element, + form: form, + message: message, + params: paramValues, + rules: rules, + messages: messages + }); + } + }); + + $.extend(rules, { "__dummy__": true }); + + if (!skipAttach) { + valInfo.attachValidation(); + } + }, + + parse: function (selector) { + /// + /// Parses all the HTML elements in the specified selector. It looks for input elements decorated + /// with the [data-val=true] attribute value and enables validation according to the data-val-* + /// attribute values. + /// + /// Any valid jQuery selector. + + // $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one + // element with data-val=true + var $selector = $(selector), + $forms = $selector.parents() + .addBack() + .filter("form") + .add($selector.find("form")) + .has("[data-val=true]"); + + $selector.find("[data-val=true]").each(function () { + $jQval.unobtrusive.parseElement(this, true); + }); + + $forms.each(function () { + var info = validationInfo(this); + if (info) { + info.attachValidation(); + } + }); + } + }; + + adapters = $jQval.unobtrusive.adapters; + + adapters.add = function (adapterName, params, fn) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). + /// [Optional] An array of parameter names (strings) that will + /// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and + /// mmmm is the parameter name). + /// The function to call, which adapts the values from the HTML + /// attributes into jQuery Validate rules and/or messages. + /// + if (!fn) { // Called with no params, just a function + fn = params; + params = []; + } + this.push({ name: adapterName, params: params, adapt: fn }); + return this; + }; + + adapters.addBool = function (adapterName, ruleName) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where + /// the jQuery Validate validation rule has no parameter values. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). + /// [Optional] The name of the jQuery Validate rule. If not provided, the value + /// of adapterName will be used instead. + /// + return this.add(adapterName, function (options) { + setValidationValues(options, ruleName || adapterName, true); + }); + }; + + adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where + /// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and + /// one for min-and-max). The HTML parameters are expected to be named -min and -max. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). + /// The name of the jQuery Validate rule to be used when you only + /// have a minimum value. + /// The name of the jQuery Validate rule to be used when you only + /// have a maximum value. + /// The name of the jQuery Validate rule to be used when you + /// have both a minimum and maximum value. + /// [Optional] The name of the HTML attribute that + /// contains the minimum value. The default is "min". + /// [Optional] The name of the HTML attribute that + /// contains the maximum value. The default is "max". + /// + return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) { + var min = options.params.min, + max = options.params.max; + + if (min && max) { + setValidationValues(options, minMaxRuleName, [min, max]); + } + else if (min) { + setValidationValues(options, minRuleName, min); + } + else if (max) { + setValidationValues(options, maxRuleName, max); + } + }); + }; + + adapters.addSingleVal = function (adapterName, attribute, ruleName) { + /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where + /// the jQuery Validate validation rule has a single value. + /// The name of the adapter to be added. This matches the name used + /// in the data-val-nnnn HTML attribute(where nnnn is the adapter name). + /// [Optional] The name of the HTML attribute that contains the value. + /// The default is "val". + /// [Optional] The name of the jQuery Validate rule. If not provided, the value + /// of adapterName will be used instead. + /// + return this.add(adapterName, [attribute || "val"], function (options) { + setValidationValues(options, ruleName || adapterName, options.params[attribute]); + }); + }; + + $jQval.addMethod("__dummy__", function (value, element, params) { + return true; + }); + + $jQval.addMethod("regex", function (value, element, params) { + var match; + if (this.optional(element)) { + return true; + } + + match = new RegExp(params).exec(value); + return (match && (match.index === 0) && (match[0].length === value.length)); + }); + + $jQval.addMethod("nonalphamin", function (value, element, nonalphamin) { + var match; + if (nonalphamin) { + match = value.match(/\W/g); + match = match && match.length >= nonalphamin; + } + return match; + }); + + if ($jQval.methods.extension) { + adapters.addSingleVal("accept", "mimtype"); + adapters.addSingleVal("extension", "extension"); + } else { + // for backward compatibility, when the 'extension' validation method does not exist, such as with versions + // of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for + // validating the extension, and ignore mime-type validations as they are not supported. + adapters.addSingleVal("extension", "extension", "accept"); + } + + adapters.addSingleVal("regex", "pattern"); + adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url"); + adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range"); + adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength"); + adapters.add("equalto", ["other"], function (options) { + var prefix = getModelPrefix(options.element.name), + other = options.params.other, + fullOtherName = appendModelPrefix(other, prefix), + element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0]; + + setValidationValues(options, "equalTo", element); + }); + adapters.add("required", function (options) { + // jQuery Validate equates "required" with "mandatory" for checkbox elements + if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") { + setValidationValues(options, "required", true); + } + }); + adapters.add("remote", ["url", "type", "additionalfields"], function (options) { + var value = { + url: options.params.url, + type: options.params.type || "GET", + data: {} + }, + prefix = getModelPrefix(options.element.name); + + $.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) { + var paramName = appendModelPrefix(fieldName, prefix); + value.data[paramName] = function () { + var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']"); + // For checkboxes and radio buttons, only pick up values from checked fields. + if (field.is(":checkbox")) { + return field.filter(":checked").val() || field.filter(":hidden").val() || ''; + } + else if (field.is(":radio")) { + return field.filter(":checked").val() || ''; + } + return field.val(); + }; + }); + + setValidationValues(options, "remote", value); + }); + adapters.add("password", ["min", "nonalphamin", "regex"], function (options) { + if (options.params.min) { + setValidationValues(options, "minlength", options.params.min); + } + if (options.params.nonalphamin) { + setValidationValues(options, "nonalphamin", options.params.nonalphamin); + } + if (options.params.regex) { + setValidationValues(options, "regex", options.params.regex); + } + }); + adapters.add("fileextensions", ["extensions"], function (options) { + setValidationValues(options, "extension", options.params.extensions); + }); + + $(function () { + $jQval.unobtrusive.parse(document); + }); + + return $jQval.unobtrusive; +})); diff --git a/MyApp.Client/src/App.tsx b/MyApp.Client/src/App.tsx index 600a2d8..7807374 100644 --- a/MyApp.Client/src/App.tsx +++ b/MyApp.Client/src/App.tsx @@ -5,10 +5,11 @@ import { AutoUis, ShellCommand, ReactLogo, TailwindLogo, TypeScriptLogo, ViteLog import HelloApi from './components/HelloApi' import { appAuth } from './lib/auth' import { Link } from 'react-router-dom' +import Footer from './components/Footer' function App() { const { user, signOut } = appAuth() - return ( + return (<> @@ -57,7 +58,8 @@ function App() { - ) +