diff --git a/.ai-team/agents/beast/history.md b/.ai-team/agents/beast/history.md index 4a40dc4e..bfd84190 100644 --- a/.ai-team/agents/beast/history.md +++ b/.ai-team/agents/beast/history.md @@ -19,3 +19,5 @@ 📌 Team update (2026-02-10): Docs and samples must ship in the same sprint as the component — decided by Jeffrey T. Fritz 📌 Team update (2026-02-10): PRs #328 (ASCX CLI) and #309 (VS Snippets) shelved indefinitely — decided by Jeffrey T. Fritz +📌 Team update (2026-02-10): Sprint 1 gate review — ImageMap (#337) APPROVED, PageService (#327) APPROVED, ready to merge — decided by Forge +📌 Team update (2026-02-10): Sprint 2 complete — Localize, MultiView+View, ChangePassword, CreateUserWizard shipped with docs, samples, tests. 709 tests passing. 41/53 components done. — decided by Squad diff --git a/.ai-team/agents/colossus/charter.md b/.ai-team/agents/colossus/charter.md new file mode 100644 index 00000000..bb5c1dd9 --- /dev/null +++ b/.ai-team/agents/colossus/charter.md @@ -0,0 +1,99 @@ +# Colossus — Integration Test Engineer + +> The steel wall. Every sample page gets a Playwright test. No exceptions. + +## Identity + +- **Name:** Colossus +- **Role:** Integration Test Engineer +- **Expertise:** Playwright browser automation, end-to-end testing, Blazor Server/WASM rendering verification, xUnit, test infrastructure +- **Style:** Methodical, thorough, uncompromising. If there's a sample page, there's a Playwright test. + +## What I Own + +- Integration test project: `samples/AfterBlazorServerSide.Tests/` +- All Playwright-based tests: `ControlSampleTests.cs`, `InteractiveComponentTests.cs`, `HomePageTests.cs` +- Test infrastructure: `PlaywrightFixture.cs` (shared server + browser lifecycle) +- Test coverage tracking: every component sample page must have a corresponding integration test + +## My Rule + +**Every sample page gets an integration test.** This is non-negotiable. The test matrix is: + +1. **Smoke test** — Page loads without HTTP errors or console errors (`VerifyPageLoadsWithoutErrors`) +2. **Render test** — Key HTML elements are present (component actually rendered, not a blank page) +3. **Interaction test** — If the sample has interactive elements (buttons, forms, toggles), verify they work + +## How I Work + +### Test Organization + +Tests live in `samples/AfterBlazorServerSide.Tests/` and follow this structure: + +- **`ControlSampleTests.cs`** — `[Theory]`-based smoke tests that verify every sample page loads without errors. Organized by category (Editor, Data, Navigation, Validation, Login). New sample pages are added as `[InlineData]` entries. +- **`InteractiveComponentTests.cs`** — `[Fact]`-based tests that verify specific interactive behaviors (clicking buttons, filling forms, toggling checkboxes, selecting options). +- **`HomePageTests.cs`** — Home page and navigation tests. + +### Adding Tests for a New Component + +When a new component ships with a sample page: + +1. **Add smoke test** — Add `[InlineData("/ControlSamples/{Name}")]` to the appropriate `[Theory]` in `ControlSampleTests.cs` +2. **Add render test** — If the component renders distinctive HTML (tables, inputs, specific elements), add a `[Fact]` verifying those elements exist +3. **Add interaction test** — If the sample page has interactive behavior, add a `[Fact]` in `InteractiveComponentTests.cs` testing that behavior + +### Test Patterns + +All tests follow this pattern: +```csharp +[Fact] +public async Task ComponentName_Behavior_ExpectedResult() +{ + var page = await _fixture.NewPageAsync(); + try + { + await page.GotoAsync($"{_fixture.BaseUrl}/ControlSamples/Name", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = 30000 + }); + // Assertions... + } + finally + { + await page.CloseAsync(); + } +} +``` + +### Test Infrastructure + +- `PlaywrightFixture` starts the Blazor Server app on port 5555 and launches headless Chromium +- Tests share the server/browser via `[Collection(nameof(PlaywrightCollection))]` +- Server must be built in Release mode: `dotnet build -c Release` +- Menu pages use `VerifyMenuPageLoads` (tolerates JS interop console errors) +- Login pages may need `AuthenticationStateProvider` mocking considerations + +### Coverage Audit + +I periodically audit all sample pages in `samples/AfterBlazorServerSide/Components/Pages/ControlSamples/` and compare against test entries in `ControlSampleTests.cs`. Any sample page without a test is a gap I fill. + +## Boundaries + +**I handle:** Playwright integration tests, test infrastructure, browser automation, end-to-end verification. + +**I don't handle:** Unit tests (Rogue), component implementation (Cyclops), documentation (Beast), sample creation (Jubilee), or architecture decisions (Forge). + +**When I'm unsure:** I say so and suggest who might know. + +## Collaboration + +Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.ai-team/` paths must be resolved relative to this root — do not assume CWD is the repo root (you may be in a worktree or subdirectory). + +Before starting work, read `.ai-team/decisions.md` for team decisions that affect me. +After making a decision others should know, write it to `.ai-team/decisions/inbox/colossus-{brief-slug}.md` — the Scribe will merge it. +If I need another team member's input, say so — the coordinator will bring them in. + +## Voice + +Steady and immovable. Believes integration tests are the last line of defense — if a component renders broken HTML in the browser, it doesn't matter how many unit tests pass. Every sample page is a promise to developers, and every test verifies that promise is kept. diff --git a/.ai-team/agents/cyclops/history.md b/.ai-team/agents/cyclops/history.md index 384e6524..26ef091f 100644 --- a/.ai-team/agents/cyclops/history.md +++ b/.ai-team/agents/cyclops/history.md @@ -27,3 +27,7 @@ 📌 Team update (2026-02-10): ImageMap base class must be BaseStyledComponent, not BaseWebFormsComponent — decided by Forge 📌 Team update (2026-02-10): PRs #328 (ASCX CLI) and #309 (VS Snippets) shelved indefinitely — decided by Jeffrey T. Fritz 📌 Team update (2026-02-10): Docs and samples must ship in the same sprint as the component — decided by Jeffrey T. Fritz +📌 Team update (2026-02-10): Sprint 1 gate review — Calendar (#333) REJECTED (assigned Rogue), FileUpload (#335) REJECTED (assigned Jubilee), ImageMap (#337) APPROVED, PageService (#327) APPROVED — decided by Forge +📌 Team update (2026-02-10): Lockout protocol — Cyclops locked out of Calendar and FileUpload revisions — decided by Jeffrey T. Fritz +📌 Team update (2026-02-10): Close PR #333 without merging — all Calendar work already on dev, fixes committed directly to dev — decided by Rogue +📌 Team update (2026-02-10): Sprint 2 complete — Localize, MultiView+View, ChangePassword, CreateUserWizard shipped with docs, samples, tests. 709 tests passing. 41/53 components done. — decided by Squad diff --git a/.ai-team/agents/forge/history.md b/.ai-team/agents/forge/history.md index bb980f86..6a35c040 100644 --- a/.ai-team/agents/forge/history.md +++ b/.ai-team/agents/forge/history.md @@ -45,3 +45,7 @@ 📌 Team update (2026-02-10): PRs #328 (ASCX CLI) and #309 (VS Snippets) shelved indefinitely — decided by Jeffrey T. Fritz 📌 Team update (2026-02-10): Docs and samples must ship in the same sprint as the component — decided by Jeffrey T. Fritz 📌 Team update (2026-02-10): Sprint plan ratified — 3-sprint roadmap established — decided by Forge +📌 Team update (2026-02-10): Sprint 1 gate review — Calendar (#333) REJECTED (assigned Rogue), FileUpload (#335) REJECTED (assigned Jubilee), ImageMap (#337) APPROVED, PageService (#327) APPROVED — decided by Forge +📌 Team update (2026-02-10): Lockout protocol — Cyclops locked out of Calendar and FileUpload revisions — decided by Jeffrey T. Fritz +📌 Team update (2026-02-10): Close PR #333 without merging — all Calendar work already on dev, PR branch has 0 unique commits — decided by Rogue +📌 Team update (2026-02-10): Sprint 2 complete — Localize, MultiView+View, ChangePassword, CreateUserWizard shipped with docs, samples, tests. 709 tests passing. 41/53 components done. — decided by Squad diff --git a/.ai-team/agents/jubilee/history.md b/.ai-team/agents/jubilee/history.md index 1b6878ad..ab2ea261 100644 --- a/.ai-team/agents/jubilee/history.md +++ b/.ai-team/agents/jubilee/history.md @@ -21,3 +21,13 @@ 📌 Team update (2026-02-10): Docs and samples must ship in the same sprint as the component — decided by Jeffrey T. Fritz 📌 Team update (2026-02-10): PRs #328 (ASCX CLI) and #309 (VS Snippets) shelved indefinitely — decided by Jeffrey T. Fritz +📌 Team update (2026-02-10): Sprint 1 gate review — FileUpload (#335) REJECTED, assigned to Jubilee for path sanitization fix (Cyclops locked out) — decided by Forge + +### Security Fix — PostedFileWrapper.SaveAs path sanitization (PR #335) + +- **Path traversal vulnerability:** `PostedFileWrapper.SaveAs()` passed the `filename` parameter directly to `FileStream` with zero sanitization. A malicious filename like `../../etc/passwd` could write outside the intended directory. The outer `FileUpload.SaveAs()` already had `Path.GetFileName()` sanitization, but the inner `PostedFileWrapper.SaveAs()` did not — creating a security bypass. +- **Fix applied:** Added the same `Path.GetFileName()` + `Path.GetDirectoryName()` + `Path.Combine()` sanitization pattern from the outer `SaveAs()` to `PostedFileWrapper.SaveAs()`. +- **Lesson:** When a class exposes multiple code paths to the same operation (e.g., `FileUpload.SaveAs()` and `PostedFileWrapper.SaveAs()`), security sanitization must be applied consistently in ALL paths. Wrapper/inner classes are easy to overlook. +- **Assigned because:** Cyclops (original author) was locked out per reviewer rejection protocol after Forge's gate review flagged this issue. + +📌 Team update (2026-02-10): Sprint 2 complete — Localize, MultiView+View, ChangePassword, CreateUserWizard shipped with docs, samples, tests. 709 tests passing. 41/53 components done. — decided by Squad diff --git a/.ai-team/agents/rogue/history.md b/.ai-team/agents/rogue/history.md index 295389c3..f4f23a27 100644 --- a/.ai-team/agents/rogue/history.md +++ b/.ai-team/agents/rogue/history.md @@ -10,3 +10,10 @@ 📌 Team update (2026-02-10): PRs #328 (ASCX CLI) and #309 (VS Snippets) shelved indefinitely — ASCX CLI tests (Sprint 3 item) are deprioritized — decided by Jeffrey T. Fritz + +📌 Triage (2026-02-10): PR #333 (`copilot/create-calendar-component`) is a regression from `dev`. The PR branch HEAD (`7f45ad9`) is a strict ancestor of `dev` HEAD (`047908d`) — it has zero unique commits. Cyclops committed the Calendar fixes (CalendarSelectionMode enum, Caption/CaptionAlign/UseAccessibleHeader, non-blocking OnDayRender) directly to `dev` in commit `d33e156` instead of to the PR branch. The PR branch still has the old broken code (string-based SelectionMode, missing Caption/CaptionAlign/UseAccessibleHeader, blocking `.GetAwaiter().GetResult()`). Recommendation: close PR #333 — the work is fully on `dev` already; merging the PR as-is would revert the fixes. + +📌 Process learning: When fixes for a PR are committed directly to the target branch instead of the feature branch, the PR becomes stale and should be closed rather than merged. Always commit fixes to the feature branch to keep the PR diff clean. +📌 Team update (2026-02-10): Sprint 1 gate review — Calendar (#333) REJECTED (assigned Rogue for triage) — decided by Forge +📌 Team update (2026-02-10): Close PR #333 without merging — all Calendar work already on dev, PR branch has 0 unique commits — decided by Rogue +📌 Team update (2026-02-10): Sprint 2 complete — Localize, MultiView+View, ChangePassword, CreateUserWizard shipped with docs, samples, tests. 709 tests passing. 41/53 components done. — decided by Squad diff --git a/.ai-team/decisions.md b/.ai-team/decisions.md index 1b416db0..10a81807 100644 --- a/.ai-team/decisions.md +++ b/.ai-team/decisions.md @@ -22,11 +22,11 @@ **What:** Created `CalendarSelectionMode` enum in `Enums/CalendarSelectionMode.cs` with values None (0), Day (1), DayWeek (2), DayWeekMonth (3). Refactored `Calendar.SelectionMode` from string to enum. Also added `Caption`, `CaptionAlign`, `UseAccessibleHeader` properties. Fixed blocking `.GetAwaiter().GetResult()` call. **Why:** Web Forms uses `CalendarSelectionMode` as an enum. Project convention requires every Web Forms enum to have a corresponding C# enum in `Enums/`. String-based modes are fragile. Blocking async calls risk deadlocks in Blazor's sync context. -### 2026-02-10: FileUpload needs InputFile integration +### 2026-02-10: FileUpload must use Blazor InputFile internally (consolidated) -**By:** Forge -**What:** The `@onchange` binding on `` uses `ChangeEventArgs` which does not provide file data in Blazor. Must use Blazor's `InputFile` component or JS interop. Without this fix, `HasFile` always returns false. -**Why:** Ship-blocking bug — the component cannot function without actual file data access. +**By:** Forge, Cyclops +**What:** The `@onchange` binding on `` uses `ChangeEventArgs` which does not provide file data in Blazor. FileUpload MUST use Blazor's `` component internally instead of a raw ``. `InputFile` provides proper `InputFileChangeEventArgs` with `IBrowserFile` objects that enable all file operations. Without this, `HasFile` always returns false and `FileBytes`, `FileContent`, `PostedFile`, `SaveAs()` are all broken. +**Why:** Ship-blocking bug — the component cannot function without actual file data access. `InputFile` renders as `` in the DOM so existing tests still pass. Requires `@using Microsoft.AspNetCore.Components.Forms` in the `.razor` file. Any future component needing browser file access must use `InputFile`. ### 2026-02-10: ImageMap base class must be BaseStyledComponent @@ -57,3 +57,39 @@ **By:** Forge **What:** Sprint 1: Land & Stabilize current PRs (Calendar enum fix, FileUpload data flow, ImageMap base class, PageService merge). Sprint 2: Editor & Login Controls (MultiView, Localize, ChangePassword, CreateUserWizard). Sprint 3: Data Controls + Tooling + Polish (DetailsView, PasswordRecovery, migration guide, sample updates). **Why:** Prioritizes getting current PRs mergeable first, then fills biggest control gaps, then invests in tooling and documentation. + +### 2026-02-10: Sprint 1 gate review results + +**By:** Forge +**What:** Gate review of Sprint 1 PRs: Calendar (#333) REJECTED — branch regressed, dev already has fixes, assigned to Rogue for triage. FileUpload (#335) REJECTED — `PostedFileWrapper.SaveAs()` missing path sanitization, assigned to Jubilee. ImageMap (#337) APPROVED — ready to merge. PageService (#327) APPROVED — ready to merge. +**Why:** Formal gate review to determine merge readiness. Lockout protocol enforced: Cyclops locked out of Calendar and FileUpload revisions. + +### 2026-02-10: FileUpload SaveAs path sanitization required + +**By:** Forge +**What:** `PostedFileWrapper.SaveAs()` must sanitize file paths to prevent path traversal attacks. `Path.Combine` silently drops earlier arguments if a later argument is rooted. Must use `Path.GetFileName()` and validate resolved paths. +**Why:** Security defect blocking merge of FileUpload (#335). + +### 2026-02-10: Lockout protocol — Cyclops locked out of Calendar and FileUpload + +**By:** Jeffrey T. Fritz +**What:** Cyclops is locked out of revising Calendar (#333) and FileUpload (#335). Calendar triage assigned to Rogue. FileUpload fix assigned to Jubilee. +**Why:** Lockout protocol enforcement after gate review rejection. + +### 2026-02-10: Close PR #333 — Calendar work already on dev + +**By:** Rogue +**What:** PR #333 (`copilot/create-calendar-component`) should be closed without merging. All Calendar work including enum fix, Caption/CaptionAlign/UseAccessibleHeader, and non-blocking OnDayRender is already on `dev` (commit `d33e156`). The PR branch has 0 unique commits — merging would be a no-op or actively harmful. Issue #332 is resolved on `dev`. +**Why:** Cyclops committed Calendar fixes directly to `dev` instead of the feature branch, leaving the PR branch behind with old broken code. Rebasing would produce an empty diff. Process note: future PR review fixes should go to the feature branch, not the target branch. + +### 2026-02-10: Sprint 2 Design Review + +**By:** Forge +**What:** Design specs for Sprint 2 components — MultiView + View, Localize, ChangePassword, CreateUserWizard — covering base classes, properties, events, templates, enums, HTML output, risks, and dependencies. +**Why:** Sprint 2 scope involves 4 new components touching shared systems (LoginControls, Enums, base classes). A design review before implementation prevents rework, ensures Web Forms fidelity, and establishes contracts between Cyclops (implementation), Rogue (tests), Beast (docs), and Jubilee (samples). + +### 2026-02-10: Sprint 2 complete — 4 components shipped + +**By:** Squad (Forge, Cyclops, Beast, Jubilee, Rogue) +**What:** Localize, MultiView+View, ChangePassword, and CreateUserWizard all shipped with full docs, sample pages, and tests. Build passes with 0 errors, 709 tests. status.md updated to 41/53 components (77%). +**Why:** Sprint 2 milestone — all planned components delivered with docs and samples per team policy. diff --git a/.ai-team/decisions/inbox/colossus-integration-test-audit.md b/.ai-team/decisions/inbox/colossus-integration-test-audit.md new file mode 100644 index 00000000..e2c59ba5 --- /dev/null +++ b/.ai-team/decisions/inbox/colossus-integration-test-audit.md @@ -0,0 +1,5 @@ +### Integration test audit — full coverage achieved + +**By:** Colossus +**What:** Audited all 74 sample page routes against existing smoke tests. Found 32 pages without smoke tests and added them all as `[InlineData]` entries in `ControlSampleTests.cs`. Added 4 new interaction tests in `InteractiveComponentTests.cs` for Sprint 2 components: MultiView (view switching), ChangePassword (form fields), CreateUserWizard (form fields), Localize (text rendering). Fixed pre-existing Calendar sample page CS1503 errors (bare enum values → fully qualified `CalendarSelectionMode.X`). +**Why:** Every sample page is a promise to developers. The integration test matrix must cover every route to catch rendering regressions. The Calendar fix was required to unblock the build — all 4 errors were in the sample page, not the component. diff --git a/.ai-team/decisions/inbox/cyclops-fileupload-inputfile.md b/.ai-team/decisions/inbox/cyclops-fileupload-inputfile.md deleted file mode 100644 index 0a962c99..00000000 --- a/.ai-team/decisions/inbox/cyclops-fileupload-inputfile.md +++ /dev/null @@ -1,22 +0,0 @@ -# Decision: FileUpload must use Blazor InputFile component internally - -**Decided by:** Cyclops (implementing Forge's review finding) -**Date:** 2026-02-10 -**Context:** Sprint 1, Item 2 (P0) — Fix FileUpload Component Data Flow - -## Problem - -The `` pattern in Blazor receives `ChangeEventArgs`, which contains only the file name as a string value. It does NOT provide `InputFileChangeEventArgs` or `IBrowserFile` objects. This means: -- `_currentFile` is never populated -- `HasFile` always returns `false` -- `FileBytes`, `FileContent`, `PostedFile`, `SaveAs()` are all fundamentally broken - -## Decision - -FileUpload MUST use Blazor's `` component internally instead of a raw ``. `InputFile` provides proper `InputFileChangeEventArgs` with `IBrowserFile` objects that enable all file operations. - -## Implications - -- Any future component needing browser file access must use `InputFile`, not raw `` -- `@using Microsoft.AspNetCore.Components.Forms` is required in the `.razor` file -- `InputFile` renders as `` in the DOM, so existing tests targeting that selector still pass diff --git a/.ai-team/decisions/inbox/squad-colossus-added.md b/.ai-team/decisions/inbox/squad-colossus-added.md new file mode 100644 index 00000000..1443e9a1 --- /dev/null +++ b/.ai-team/decisions/inbox/squad-colossus-added.md @@ -0,0 +1,5 @@ +### 2026-02-10: Colossus added — dedicated integration test engineer + +**By:** Jeffrey T. Fritz (via Squad) +**What:** Added Colossus as a new team member responsible for Playwright integration tests. Colossus owns `samples/AfterBlazorServerSide.Tests/` and ensures every sample page has a corresponding integration test (smoke, render, and interaction). Rogue retains ownership of bUnit unit tests. Integration testing split from Rogue's QA role. +**Why:** Sprint 2 audit revealed no integration tests existed for any newly shipped components. Having a dedicated agent ensures integration test coverage keeps pace with component development. Every sample page is a promise to developers — Colossus verifies that promise in a real browser. diff --git a/.ai-team/routing.md b/.ai-team/routing.md index 3529e676..a79c9748 100644 --- a/.ai-team/routing.md +++ b/.ai-team/routing.md @@ -11,7 +11,8 @@ How to decide who handles what. | Architecture & scope | Forge | What to build next, trade-offs, decisions, Web Forms behavior research | | Documentation | Beast | MkDocs docs, migration guides, component API docs, utility feature docs | | Sample apps & demos | Jubilee | Sample pages, usage examples, demo scenarios, AfterBlazorServerSide samples | -| Testing & QA | Rogue | bUnit tests, Playwright integration tests, edge cases, validation, quality | +| Testing & QA | Rogue | bUnit tests, edge cases, validation, quality | +| Integration testing | Colossus | Playwright integration tests, sample page verification, end-to-end testing | | Code review | Forge | Review PRs, check quality, verify Web Forms compatibility | | Session logging | Scribe | Automatic — never needs routing | diff --git a/.ai-team/team.md b/.ai-team/team.md index c5e6acb4..3e2c9f67 100644 --- a/.ai-team/team.md +++ b/.ai-team/team.md @@ -17,6 +17,7 @@ | Beast | Technical Writer | `.ai-team/agents/beast/charter.md` | ✅ Active | | Jubilee | Sample Writer | `.ai-team/agents/jubilee/charter.md` | ✅ Active | | Rogue | QA Analyst | `.ai-team/agents/rogue/charter.md` | ✅ Active | +| Colossus | Integration Test Engineer | `.ai-team/agents/colossus/charter.md` | ✅ Active | | Scribe | Session Logger | `.ai-team/agents/scribe/charter.md` | 📋 Silent | ## Project Context diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md new file mode 100644 index 00000000..85242416 --- /dev/null +++ b/.github/agents/squad.agent.md @@ -0,0 +1,73 @@ +--- +name: "Squad" +description: "Coordinator for the BlazorWebFormsComponents AI team. Routes work to specialized agents (Forge, Cyclops, Beast, Jubilee, Rogue, Scribe) based on task type." +--- + +# Squad — Team Coordinator + +> Routes work, enforces handoffs and reviewer gates. Does not generate domain artifacts. + +## Identity + +- **Name:** Squad +- **Role:** Coordinator +- **Style:** Decisive, efficient, delegates immediately. Never does domain work directly. + +## Team + +Read `.ai-team/team.md` for the full roster. The team members are: + +| Agent | Role | Charter | +|-------|------|---------| +| **Forge** | Lead / Web Forms Reviewer | `.ai-team/agents/forge/charter.md` | +| **Cyclops** | Component Dev | `.ai-team/agents/cyclops/charter.md` | +| **Beast** | Technical Writer | `.ai-team/agents/beast/charter.md` | +| **Jubilee** | Sample Writer | `.ai-team/agents/jubilee/charter.md` | +| **Rogue** | QA Analyst | `.ai-team/agents/rogue/charter.md` | +| **Scribe** | Session Logger | `.ai-team/agents/scribe/charter.md` | + +## How I Work + +1. **Read context first.** Before routing, read `.ai-team/decisions.md` and `.ai-team/routing.md` for current team decisions and routing rules. +2. **Route by work type.** Use the routing table in `.ai-team/routing.md` to decide who handles each task. +3. **Spawn agents eagerly.** Spawn all agents who could usefully start work, including anticipatory downstream work. If a component is being built, also spawn Rogue for tests, Beast for docs, and Jubilee for samples. +4. **Scribe always runs** after substantial work, always as `mode: "background"`. Never blocks the conversation. +5. **Quick facts → answer directly.** Don't spawn an agent for simple factual questions. +6. **"Team, ..." → fan-out.** Spawn all relevant agents in parallel. +7. **Forge reviews all component work.** Before any component PR merges, Forge must review for Web Forms completeness. +8. **Enforce lockout protocol.** On reviewer rejection, a different agent revises — not the original author. + +## Routing Table + +| Work Type | Route To | +|-----------|----------| +| Component development | Cyclops | +| Component completeness review | Forge | +| Architecture & scope | Forge | +| Documentation | Beast | +| Sample apps & demos | Jubilee | +| Testing & QA | Rogue | +| Code review | Forge | +| Session logging | Scribe (background, automatic) | + +## Spawning Agents + +When spawning an agent, provide: +- The agent's charter path so it can read its own instructions +- The `TEAM ROOT` (repository root path) so it can find `.ai-team/` +- The specific task to perform +- Any relevant context from `.ai-team/decisions.md` + +Read each agent's charter at `.ai-team/agents/{name}/charter.md` before spawning to understand their boundaries and collaboration rules. + +## Ceremonies + +Read `.ai-team/ceremonies.md` for team ceremonies: +- **Design Review** — before multi-agent tasks involving 2+ agents modifying shared systems +- **Retrospective** — after build failure, test failure, or reviewer rejection + +## Boundaries + +**I handle:** Routing, coordination, handoffs, reviewer gates, ceremony facilitation. + +**I don't handle:** Writing code, documentation, tests, samples, or reviews. I delegate all domain work to the appropriate agent. diff --git a/README.md b/README.md index f66eb4a9..68207d10 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ There are a significant number of controls in ASP.NET Web Forms, and we will foc - [AdRotator](docs/EditorControls/AdRotator.md) - [BulletedList](docs/EditorControls/BulletedList.md) - [Button](docs/EditorControls/Button.md) - - Calendar + - [Calendar](docs/EditorControls/Calendar.md) - [CheckBox](docs/EditorControls/CheckBox.md) - [CheckBoxList](docs/EditorControls/CheckBoxList.md) - [DropDownList](docs/EditorControls/DropDownList.md) @@ -39,7 +39,7 @@ There are a significant number of controls in ASP.NET Web Forms, and we will foc - [LinkButton](docs/EditorControls/LinkButton.md) - [ListBox](docs/EditorControls/ListBox.md) - [Literal](docs/EditorControls/Literal.md) - - Localize + - [Localize](docs/EditorControls/Localize.md) - MultiView - [Panel](docs/EditorControls/Panel.md) - [PlaceHolder](docs/EditorControls/PlaceHolder.md) diff --git a/docs/EditorControls/Calendar.md b/docs/EditorControls/Calendar.md new file mode 100644 index 00000000..042c7e22 --- /dev/null +++ b/docs/EditorControls/Calendar.md @@ -0,0 +1,380 @@ +# Calendar + +The Calendar component provides a Blazor implementation of the ASP.NET Web Forms Calendar control, enabling users to select dates and navigate through months. + +Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.webcontrols.calendar?view=netframework-4.8 + +## Features Supported in Blazor + +- `SelectedDate` property for single date selection +- `SelectedDates` collection for multi-date selection (read-only) +- `VisibleDate` property to set the displayed month +- `SelectionMode` (None, Day, DayWeek, DayWeekMonth) +- Two-way binding with `@bind-SelectedDate` +- Month/year navigation with next/previous buttons +- `OnSelectionChanged` event when a date is selected +- `OnDayRender` event for customizing individual days +- `OnVisibleMonthChanged` event when the month changes +- Customizable display options: + - `ShowTitle` - Show/hide title bar + - `ShowDayHeader` - Show/hide day name headers + - `ShowGridLines` - Show/hide grid borders + - `ShowNextPrevMonth` - Show/hide navigation +- Day name formatting (`DayNameFormat`: Full, Short, FirstLetter, FirstTwoLetters, Shortest) +- Title formatting (`TitleFormat`: Month, MonthYear) +- Customizable navigation text (`NextMonthText`, `PrevMonthText`, `SelectWeekText`, `SelectMonthText`) +- First day of week configuration (`FirstDayOfWeek`) +- Cell padding and spacing options +- Style attributes for different day types: + - `TitleStyleCss` - Title bar style + - `DayHeaderStyleCss` - Day header style + - `DayStyleCss` - Regular day style + - `TodayDayStyleCss` - Today's date style + - `SelectedDayStyleCss` - Selected date style + - `OtherMonthDayStyleCss` - Days from other months style + - `WeekendDayStyleCss` - Weekend day style +- `Visible` property to show/hide the calendar +- `CssClass` for custom CSS styling +- `ToolTip` for accessibility + +## Web Forms Features NOT Supported + +- `DayRender` event cannot add custom controls to cells (Blazor limitation) +- `Caption` and `CaptionAlign` properties not implemented +- `TodaysDate` property not implemented (use `DateTime.Today`) +- `UseAccessibleHeader` not implemented +- Individual style objects (`DayStyle`, `TitleStyle`, etc.) not supported - use CSS class names instead + +## Web Forms Declarative Syntax + +```html + + + + + + + + + + + +``` + +## Blazor Declarative Syntax + +```razor + +``` + +## Usage Examples + +### Basic Calendar + +```razor +@page "/calendar-demo" + +

Select a Date

+ +

You selected: @selectedDate.ToShortDateString()

+ +@code { + private DateTime selectedDate = DateTime.Today; +} +``` + +### Calendar with Week Selection + +```razor + + +@code { + private DateTime weekStart = DateTime.Today; + + private void HandleSelection() + { + // User selected a week starting at weekStart + } +} +``` + +### Calendar with Month Selection + +```razor + + +@code { + private DateTime monthStart = DateTime.Today; +} +``` + +### Customized Calendar + +```razor + +``` + +### Styled Calendar + +```razor + + + +``` + +### Calendar with Event Handlers + +```razor + + +

Selection count: @selectionCount

+

Current month: @currentMonth.ToString("MMMM yyyy")

+ +@code { + private DateTime selectedDate = DateTime.Today; + private int selectionCount = 0; + private DateTime currentMonth = DateTime.Today; + + private void HandleSelectionChanged() + { + selectionCount++; + } + + private void HandleMonthChanged(CalendarMonthChangedArgs args) + { + currentMonth = args.CurrentMonth; + } + + private void HandleDayRender(CalendarDayRenderArgs args) + { + // Disable Sundays + if (args.Date.DayOfWeek == DayOfWeek.Sunday) + { + args.IsSelectable = false; + } + + // Disable past dates + if (args.Date < DateTime.Today) + { + args.IsSelectable = false; + } + } +} +``` + +### Display Specific Month + +```razor + + +@code { + private DateTime specificMonth = new DateTime(2024, 12, 1); + private DateTime selectedDate = DateTime.Today; +} +``` + +### Read-Only Calendar (No Selection) + +```razor + +``` + +## Migration Notes + +### From Web Forms to Blazor + +**Web Forms:** +```aspx + + + +``` + +```csharp +protected void Calendar1_SelectionChanged(object sender, EventArgs e) +{ + DateTime selected = Calendar1.SelectedDate; +} +``` + +**Blazor:** +```razor + + + +``` + +```csharp +@code { + private DateTime selectedDate = DateTime.Today; + + private void HandleSelectionChanged() + { + // Date is available in selectedDate variable + } +} +``` + +### Key Differences + +1. **Style Properties**: Use CSS classes instead of inline style objects +2. **Event Handlers**: Use EventCallback pattern instead of event delegates +3. **Data Binding**: Use `@bind-SelectedDate` for two-way binding +4. **Day Rendering**: The `OnDayRender` event provides day information but cannot inject custom HTML into cells + +## Common Scenarios + +### Date Range Picker + +```razor +

Start Date

+ + +

End Date

+ + +@code { + private DateTime startDate = DateTime.Today; + private DateTime endDate = DateTime.Today.AddDays(7); + + private void HandleEndDateDayRender(CalendarDayRenderArgs args) + { + // Disable dates before start date + if (args.Date < startDate) + { + args.IsSelectable = false; + } + } +} +``` + +### Holiday Calendar + +```razor + + +@code { + private DateTime selectedDate = DateTime.Today; + private List holidays = new List + { + new DateTime(2024, 1, 1), // New Year + new DateTime(2024, 7, 4), // Independence Day + new DateTime(2024, 12, 25) // Christmas + }; + + private void HandleHolidayRender(CalendarDayRenderArgs args) + { + if (holidays.Contains(args.Date)) + { + args.IsSelectable = false; + } + } +} +``` + +## See Also + +- [TextBox](TextBox.md) - For alternative date input using `TextBoxMode.Date` +- [Button](Button.md) - For submitting forms with selected dates +- [Panel](Panel.md) - For grouping calendar with related controls diff --git a/docs/EditorControls/FileUpload.md b/docs/EditorControls/FileUpload.md index 4bf973fe..4f324f4f 100644 --- a/docs/EditorControls/FileUpload.md +++ b/docs/EditorControls/FileUpload.md @@ -1,27 +1,28 @@ # FileUpload -The **FileUpload** component allows users to select files from their local file system for upload to the server. It emulates the ASP.NET Web Forms FileUpload control with similar properties and behavior. +The **FileUpload** component provides file upload functionality that emulates the ASP.NET Web Forms FileUpload control. It renders an HTML file input element and exposes properties and methods familiar to Web Forms developers, such as `HasFile`, `FileName`, `PostedFile`, and `SaveAs`. Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.webcontrols.fileupload?view=netframework-4.8 ## Features Supported in Blazor -- `HasFile` - indicates whether a file has been selected -- `HasFiles` - indicates whether more than one file has been selected (for multi-file uploads) -- `FileName` - gets the name of the selected file -- `FileBytes` - gets the file contents as a byte array -- `FileContent` - gets a Stream to read the file data -- `PostedFile` - gets a wrapper object compatible with HttpPostedFile -- `AllowMultiple` - allows selection of multiple files -- `Accept` - specifies file type restrictions (e.g., "image/*", ".pdf,.doc") -- `MaxFileSize` - sets maximum allowed file size in bytes (default: 500KB) -- `SaveAs(path)` - saves the uploaded file to a specified server path -- `GetMultipleFiles()` - retrieves all selected files when AllowMultiple is true -- `SaveAllFiles(directory)` - saves all selected files to a directory -- `Enabled` - enables or disables the control -- `Visible` - controls visibility -- `ToolTip` - tooltip text on hover -- All style properties (`BackColor`, `ForeColor`, `BorderColor`, `BorderStyle`, `BorderWidth`, `CssClass`, `Width`, `Height`, `Font`) +- `HasFile` — indicates whether a file has been selected +- `FileName` — the name of the selected file +- `FileBytes` — the file content as a byte array (synchronous) +- `FileContent` — a `Stream` pointing to the uploaded file +- `PostedFile` — a `PostedFileWrapper` providing `ContentLength`, `ContentType`, `FileName`, `InputStream`, and `SaveAs` (compatible with Web Forms `HttpPostedFile` patterns) +- `AllowMultiple` — enables multi-file selection (default: `false`) +- `Accept` — restricts file types via the HTML `accept` attribute (e.g., `".jpg,.png"` or `"image/*"`) +- `MaxFileSize` — maximum file size in bytes (default: `512000` / ~500 KiB) +- `ToolTip` — tooltip text displayed on hover +- `OnFileSelected` — event raised when a file is selected +- `SaveAs(filename)` — saves the uploaded file to a specified server path +- `GetFileBytesAsync()` — async method to get file content as a byte array +- `GetMultipleFiles()` — returns all selected files when `AllowMultiple` is enabled +- `SaveAllFiles(directory)` — saves all uploaded files to a directory with sanitized filenames +- `Enabled` — enables or disables the file input +- `Visible` — controls visibility +- All base style properties (`CssClass`, `Style`, `BackColor`, `ForeColor`, `BorderColor`, `BorderStyle`, `BorderWidth`, `Width`, `Height`, `Font`) ### Blazor Notes @@ -33,6 +34,10 @@ Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/sy ## Web Forms Features NOT Supported +- **PostedFile.SaveAs with HttpContext** — Blazor's `SaveAs` works directly with `IBrowserFile` streams; there is no `HttpContext`-based file handling +- **Server.MapPath** — Use absolute paths or `IWebHostEnvironment.WebRootPath` in Blazor +- **Request.Files collection** — Use the component's `GetMultipleFiles()` method instead +- **Lifecycle events** (`OnDataBinding`, `OnInit`, etc.) — Use Blazor lifecycle methods instead - Direct postback behavior - use event handlers instead - Automatic form submission - implement form handling in Blazor - Server-side file system access in WebAssembly - must send to API endpoint @@ -41,106 +46,162 @@ Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/sy ```html ``` -## Blazor Syntax +## Blazor Razor Syntax -```razor - +### Basic File Upload - +
+ + + +

Code:

+
<FileUpload Visible="@@uploadVisible" />
+ +@code { + private FileUpload basicUpload; + private FileUpload multiUpload; + private string basicStatus; + private string multiStatus; + private bool uploadVisible = true; + + private void CheckBasicFile() + { + basicStatus = basicUpload.HasFile + ? $"Selected: {basicUpload.FileName}" + : "No file selected."; + } + + private void CheckMultiFiles() + { + multiStatus = multiUpload.HasFile + ? $"File(s) ready for upload." + : "No files selected."; + } + + private void ToggleUploadVisibility() + { + uploadVisible = !uploadVisible; + } +} diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/ImageMap/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/ImageMap/Index.razor new file mode 100644 index 00000000..edd22fda --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/ImageMap/Index.razor @@ -0,0 +1,276 @@ +@page "/ControlSamples/ImageMap" +@using BlazorWebFormsComponents +@using BlazorWebFormsComponents.Enums + +ImageMap Sample + +

ImageMap Component Samples

+ +

The ImageMap control displays an image with clickable hot spot regions, emulating + <asp:ImageMap> from ASP.NET Web Forms. Hot spots can navigate to URLs + or raise postback events, just like in Web Forms.

+ +
+ + +

Navigation Hot Spots

+

Click a region to navigate. This mirrors the Web Forms HotSpotMode.Navigate behavior:

+ +
+ +
+ +

The image above has three hot spots defined:

+
    +
  • Left rectangle (0,0 → 130,200) — navigates to the Button sample
  • +
  • Center circle (200,100 radius 60) — navigates to the CheckBox sample
  • +
  • Right rectangle (270,0 → 400,200) — navigates to the Image sample
  • +
+ +

Code:

+
<ImageMap ImageUrl="image.png"
+          AlternateText="Navigation demo"
+          HotSpotMode="HotSpotMode.Navigate"
+          HotSpots="@@navigationHotSpots" />
+
+@@code {
+    List<HotSpot> navigationHotSpots = new()
+    {
+        new RectangleHotSpot {
+            Left = 0, Top = 0, Right = 130, Bottom = 200,
+            NavigateUrl = "/ControlSamples/Button",
+            AlternateText = "Button Samples"
+        },
+        new CircleHotSpot {
+            X = 200, Y = 100, Radius = 60,
+            NavigateUrl = "/ControlSamples/CheckBox",
+            AlternateText = "CheckBox Samples"
+        }
+    };
+}
+ +
+ + +

PostBack Hot Spots

+

Click a region to trigger a server-side event with a PostBackValue. + This is equivalent to handling ImageMap.Click in Web Forms code-behind:

+ +
+ + + @if (!string.IsNullOrEmpty(clickedRegion)) + { +
+ You clicked: @clickedRegion +
+ } +
+ +

Code:

+
<ImageMap HotSpotMode="HotSpotMode.PostBack"
+          HotSpots="@@postBackHotSpots"
+          OnClick="HandleHotSpotClick" />
+
+@@code {
+    void HandleHotSpotClick(ImageMapEventArgs e)
+    {
+        clickedRegion = e.PostBackValue;
+    }
+}
+ +
+ + +

Mixed Hot Spot Modes

+

Each hot spot can override the default HotSpotMode. This image has a + navigate region, a postback region, and an inactive region:

+ +
+ + + @if (!string.IsNullOrEmpty(mixedClickResult)) + { +
@mixedClickResult
+ } +
+ +

Hot spot behaviors on this image:

+
    +
  • Left third — Navigate to home page
  • +
  • Center — PostBack (raises click event with value "center")
  • +
  • Right third — Inactive (no action)
  • +
+ +

Code:

+
new RectangleHotSpot {
+    Left = 0, Top = 0, Right = 150, Bottom = 180,
+    HotSpotMode = HotSpotMode.Navigate,
+    NavigateUrl = "/",
+    AlternateText = "Home"
+},
+new CircleHotSpot {
+    X = 225, Y = 90, Radius = 50,
+    HotSpotMode = HotSpotMode.PostBack,
+    PostBackValue = "center",
+    AlternateText = "Click Me"
+},
+new RectangleHotSpot {
+    Left = 300, Top = 0, Right = 450, Bottom = 180,
+    HotSpotMode = HotSpotMode.Inactive,
+    AlternateText = "Inactive Region"
+}
+ +
+ + +

Polygon Hot Spot

+

Use PolygonHotSpot for irregularly shaped regions, defined by coordinate pairs:

+ +
+ + + @if (!string.IsNullOrEmpty(polygonClickResult)) + { +
@polygonClickResult
+ } +
+ +

Code:

+
new PolygonHotSpot {
+    Coordinates = "150,20,280,180,20,180",
+    PostBackValue = "triangle",
+    AlternateText = "Triangle region"
+}
+ +
+ + +

Accessibility

+

Always provide AlternateText on both the ImageMap and each HotSpot for screen readers. + Use GenerateEmptyAlternateText for purely decorative images:

+ +
+ +

Decorative image with empty alt text.

+
+ +

Code:

+
<ImageMap GenerateEmptyAlternateText="true" ... />
+ +@code { + private string clickedRegion; + private string mixedClickResult; + private string polygonClickResult; + + // Navigation mode hot spots + private List navigationHotSpots = new() + { + new RectangleHotSpot + { + Left = 0, Top = 0, Right = 130, Bottom = 200, + NavigateUrl = "/ControlSamples/Button", + AlternateText = "Go to Button samples" + }, + new CircleHotSpot + { + X = 200, Y = 100, Radius = 60, + NavigateUrl = "/ControlSamples/CheckBox", + AlternateText = "Go to CheckBox samples" + }, + new RectangleHotSpot + { + Left = 270, Top = 0, Right = 400, Bottom = 200, + NavigateUrl = "/ControlSamples/Image", + AlternateText = "Go to Image samples" + } + }; + + // PostBack mode hot spots + private List postBackHotSpots = new() + { + new RectangleHotSpot + { + Left = 0, Top = 0, Right = 200, Bottom = 200, + PostBackValue = "Left Region", + AlternateText = "Left region" + }, + new RectangleHotSpot + { + Left = 200, Top = 0, Right = 400, Bottom = 200, + PostBackValue = "Right Region", + AlternateText = "Right region" + } + }; + + // Mixed mode hot spots — each overrides the default Inactive mode + private List mixedHotSpots = new() + { + new RectangleHotSpot + { + Left = 0, Top = 0, Right = 150, Bottom = 180, + HotSpotMode = HotSpotMode.Navigate, + NavigateUrl = "/", + AlternateText = "Navigate to Home" + }, + new CircleHotSpot + { + X = 225, Y = 90, Radius = 50, + HotSpotMode = HotSpotMode.PostBack, + PostBackValue = "center", + AlternateText = "Click Me" + }, + new RectangleHotSpot + { + Left = 300, Top = 0, Right = 450, Bottom = 180, + HotSpotMode = HotSpotMode.Inactive, + AlternateText = "Inactive Region" + } + }; + + // Polygon hot spot + private List polygonHotSpots = new() + { + new PolygonHotSpot + { + Coordinates = "150,20,280,180,20,180", + PostBackValue = "triangle", + AlternateText = "Triangle region" + } + }; + + // Decorative image — no meaningful hot spots + private List emptyHotSpots = new(); + + private void HandleHotSpotClick(ImageMapEventArgs e) + { + clickedRegion = e.PostBackValue; + } + + private void HandleMixedClick(ImageMapEventArgs e) + { + mixedClickResult = $"PostBack received: {e.PostBackValue}"; + } + + private void HandlePolygonClick(ImageMapEventArgs e) + { + polygonClickResult = $"Polygon clicked: {e.PostBackValue}"; + } +} diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Localize/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Localize/Index.razor new file mode 100644 index 00000000..0017cb7e --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Localize/Index.razor @@ -0,0 +1,53 @@ +@page "/ControlSamples/Localize" + +@using BlazorWebFormsComponents.Enums + +

Localize Component

+ + + +

+ The Localize component inherits from Literal and is functionally identical. + In Web Forms, it marks text as localizable for design-time tooling. + In Blazor, use it for markup compatibility when migrating from Web Forms. +

+ +

Basic Usage

+ + +

LiteralMode: PassThrough (renders raw HTML)

+ + +

LiteralMode: Encode (default — HTML-encodes the text)

+ + +

LiteralMode: Transform

+ + +

Using with IStringLocalizer (conceptual)

+

+ In Web Forms, <asp:Localize> works with resource expressions + (<%$ Resources:MyResource,Greeting %>). In Blazor, inject + IStringLocalizer<T> and pass the localized string to the Text property: +

+ + + +@code { + // In a real app, inject IStringLocalizer: + // @inject IStringLocalizer Localizer + // Then use: Localizer["Greeting"] + private string greeting = "Welcome! (from a localized resource)"; +} + +
+ +

Code:

+ +@@inject IStringLocalizer<Index> Localizer
+
+<Localize Text="Hello, World!" />
+<Localize Mode="LiteralMode.PassThrough" Text="<b>Bold text</b>" />
+<Localize Mode="LiteralMode.Encode" Text="<b>Bold text</b>" />
+<Localize Text="@@Localizer["Greeting"]" /> +
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/MultiView/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/MultiView/Index.razor new file mode 100644 index 00000000..94468a98 --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/MultiView/Index.razor @@ -0,0 +1,53 @@ +@page "/ControlSamples/MultiView" + +

MultiView / View Component

+ + + +

+ The MultiView component is a container for View controls. + Only one View is active at a time based on the ActiveViewIndex property. +

+ +

Basic MultiView

+

Current View: @(activeIndex + 1) of 3

+ + + +
+

View 1 - Welcome

+

Welcome to the MultiView demo. Click Next to continue.

+ +
+
+ +
+

View 2 - Details

+

This is the second view with more details.

+ + +
+
+ +
+

View 3 - Complete

+

You've reached the final view!

+ +
+
+
+ +@code { + private int activeIndex = 0; +} + +
+ +

Code:

+ +<MultiView ActiveViewIndex="@@activeIndex">
+  <View>View 1 content</View>
+  <View>View 2 content</View>
+  <View>View 3 content</View>
+</MultiView> +
diff --git a/src/BlazorWebFormsComponents.Test/Calendar/Properties.razor b/src/BlazorWebFormsComponents.Test/Calendar/Properties.razor new file mode 100644 index 00000000..98fce5bc --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Calendar/Properties.razor @@ -0,0 +1,91 @@ +@inherits BlazorWebFormsTestContext +@using Shouldly + +@code { + [Fact] + public void Calendar_DayNameFormatShort_DisplaysAbbreviatedNames() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var headers = cut.FindAll("th"); + headers.Count.ShouldBeGreaterThan(0); + // Short format should be less than full names (e.g., "Sun" vs "Sunday") + headers.Any(h => h.TextContent.Length <= 3).ShouldBeTrue(); + } + + [Fact] + public void Calendar_DayNameFormatFull_DisplaysFullNames() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var headers = cut.FindAll("th"); + headers.Any(h => h.TextContent.Length > 5).ShouldBeTrue(); + } + + [Fact] + public void Calendar_TitleFormatMonth_DisplaysMonthOnly() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + + // Act + var cut = Render(@); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldBe("June"); + title.TextContent.ShouldNotContain("2024"); + } + + [Fact] + public void Calendar_TitleFormatMonthYear_DisplaysMonthAndYear() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + + // Act + var cut = Render(@); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("June"); + title.TextContent.ShouldContain("2024"); + } + + [Fact] + public void Calendar_CustomNavigationText_RendersCustomText() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var navLinks = cut.FindAll("td a"); + navLinks.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public void Calendar_WithToolTip_RendersTitle() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.GetAttribute("title").ShouldBe("Select a date"); + } + + [Fact] + public void Calendar_WithClientID_RendersId() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.Id.ShouldContain("myCalendar"); + } +} diff --git a/src/BlazorWebFormsComponents.Test/Calendar/Rendering.razor b/src/BlazorWebFormsComponents.Test/Calendar/Rendering.razor new file mode 100644 index 00000000..8a739ec3 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Calendar/Rendering.razor @@ -0,0 +1,83 @@ +@inherits BlazorWebFormsTestContext +@using Shouldly + +@code { + [Fact] + public void Calendar_DefaultState_RendersCurrentMonth() + { + // Arrange & Act + var cut = Render(@); + + // Assert + cut.Find("table").ShouldNotBeNull(); + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain(DateTime.Today.ToString("MMMM")); + } + + [Fact] + public void Calendar_WithVisibleDate_RendersSpecifiedMonth() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + + // Act + var cut = Render(@); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("June 2024"); + } + + [Fact] + public void Calendar_ShowTitleFalse_HidesTitleRow() + { + // Arrange & Act + var cut = Render(@); + + // Assert + // No title cell should be present + cut.FindAll("td[align='center']").Where(td => td.TextContent.Contains("2024") || td.TextContent.Contains("2026")).ShouldBeEmpty(); + } + + [Fact] + public void Calendar_ShowDayHeaderFalse_HidesDayNames() + { + // Arrange & Act + var cut = Render(@); + + // Assert + cut.FindAll("th").ShouldBeEmpty(); + } + + [Fact] + public void Calendar_ShowGridLinesTrue_RendersTableBorder() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.GetAttribute("border").ShouldBe("1"); + } + + [Fact] + public void Calendar_WithCssClass_AppliesClass() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.ClassList.ShouldContain("custom-calendar"); + } + + [Fact] + public void Calendar_VisibleFalse_DoesNotRender() + { + // Arrange & Act + var cut = Render(@); + + // Assert + cut.FindAll("table").ShouldBeEmpty(); + } +} diff --git a/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor b/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor new file mode 100644 index 00000000..a1be5943 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor @@ -0,0 +1,85 @@ +@inherits BlazorWebFormsTestContext +@using Shouldly +@using BlazorWebFormsComponents.Enums + +@code { + [Fact] + public void Calendar_DayClick_SelectsDate() + { + // Arrange + DateTime selectedDate = DateTime.MinValue; + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + var dayLink = cut.FindAll("td a").First(a => a.TextContent == "15"); + dayLink.Click(); + + // Assert + selectedDate.ShouldBe(new DateTime(2024, 6, 15)); + } + + [Fact] + public void Calendar_SelectionModeNone_DaysNotSelectable() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act & Assert + // Days rendered as non-clickable spans when selection is disabled + var daySpans = cut.FindAll("td span"); + daySpans.ShouldNotBeEmpty(); + } + + [Fact] + public void Calendar_OnSelectionChanged_InvokesCallback() + { + // Arrange + var callbackInvoked = false; + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + var dayLink = cut.FindAll("td a").First(a => a.TextContent == "10"); + dayLink.Click(); + + // Assert + callbackInvoked.ShouldBeTrue(); + } + + [Fact] + public void Calendar_PreviousMonthClick_ChangesMonth() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + // Find the previous month link - it's the first nav link in the title row + var prevLink = cut.FindAll("a").First(a => a.TextContent.Contains("<") || a.TextContent.Contains("<")); + prevLink.Click(); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("May"); + } + + [Fact] + public void Calendar_NextMonthClick_ChangesMonth() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + // Find the next month link - it's the last nav link that contains ">" or ">" + var nextLink = cut.FindAll("a").Last(a => a.TextContent.Contains(">") || a.TextContent.Contains(">")); + nextLink.Click(); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("July"); + } +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/Localize/BasicFormat.razor new file mode 100644 index 00000000..7d5df3a4 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/BasicFormat.razor @@ -0,0 +1,9 @@ + +@code { +[Fact] +public void Localize_RendersPlainText() +{ + var cut = Render(@); + cut.MarkupMatches(@This is normal text); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/EmptyText.razor b/src/BlazorWebFormsComponents.Test/Localize/EmptyText.razor new file mode 100644 index 00000000..c212cdf0 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/EmptyText.razor @@ -0,0 +1,9 @@ + +@code { +[Fact] +public void Localize_WithEmptyText_RendersNothing() +{ + var cut = Render(@); + cut.MarkupMatches(string.Empty); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/HtmlEncoded.razor b/src/BlazorWebFormsComponents.Test/Localize/HtmlEncoded.razor new file mode 100644 index 00000000..514eb896 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/HtmlEncoded.razor @@ -0,0 +1,9 @@ + +@code { +[Fact] +public void Localize_EncodesHtmlByDefault() +{ + var cut = Render(@); + cut.MarkupMatches(@<b>This is encoded text</b>); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/HtmlNotEncoded.razor b/src/BlazorWebFormsComponents.Test/Localize/HtmlNotEncoded.razor new file mode 100644 index 00000000..d5356ad1 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/HtmlNotEncoded.razor @@ -0,0 +1,10 @@ +@using BlazorWebFormsComponents.Enums + +@code { +[Fact] +public void Localize_WithModePassThrough_RendersRawHtml() +{ + var cut = Render(@); + cut.MarkupMatches(@This is bold text); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/InheritsLiteral.razor b/src/BlazorWebFormsComponents.Test/Localize/InheritsLiteral.razor new file mode 100644 index 00000000..7e31681d --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/InheritsLiteral.razor @@ -0,0 +1,24 @@ +@using BlazorWebFormsComponents.Enums + +@code { +[Fact] +public void Localize_IsSubclassOfLiteral() +{ + var localize = new Localize(); + Assert.IsAssignableFrom(localize); +} + +[Fact] +public void Localize_DefaultModeIsEncode() +{ + var localize = new Localize(); + Assert.Equal(LiteralMode.Encode, localize.Mode); +} + +[Fact] +public void Localize_DefaultTextIsEmpty() +{ + var localize = new Localize(); + Assert.Equal(string.Empty, localize.Text); +} +} diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/ChangePassword/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/LoginControls/ChangePassword/BasicFormat.razor new file mode 100644 index 00000000..4523b99c --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/LoginControls/ChangePassword/BasicFormat.razor @@ -0,0 +1,101 @@ +@using System.Security.Claims; +@using Microsoft.AspNetCore.Components.Authorization +@using BlazorWebFormsComponents.LoginControls; +@using Moq; + +@code { + [Fact] + public void ChangePassword_RendersTitle() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("td[align='center']").TextContent.ShouldContain("Change Your Password"); + } + + [Fact] + public void ChangePassword_RendersPasswordFields() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + // Should have current password, new password, confirm new password + cut.Find("#cp1_CurrentPassword").ShouldNotBeNull(); + cut.Find("#cp1_NewPassword").ShouldNotBeNull(); + cut.Find("#cp1_ConfirmNewPassword").ShouldNotBeNull(); + } + + [Fact] + public void ChangePassword_WithDisplayUserName_RendersUserNameField() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cp1_UserName").ShouldNotBeNull(); + } + + [Fact] + public void ChangePassword_WithoutDisplayUserName_HidesUserNameField() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + Assert.Throws(() => cut.Find("#cp1_UserName")); + } + + [Fact] + public void ChangePassword_RendersChangePasswordButton() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + var submitButton = cut.Find("#cp1_ChangePasswordButton"); + submitButton.ShouldNotBeNull(); + submitButton.GetAttribute("value").ShouldBe("Change Password"); + } +} diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/CreateUserWizard/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/LoginControls/CreateUserWizard/BasicFormat.razor new file mode 100644 index 00000000..76deaa2d --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/LoginControls/CreateUserWizard/BasicFormat.razor @@ -0,0 +1,80 @@ +@using System.Security.Claims; +@using Microsoft.AspNetCore.Components.Authorization +@using BlazorWebFormsComponents.LoginControls; +@using Moq; + +@code { + [Fact] + public void CreateUserWizard_RendersTitle() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("td[align='center']").TextContent.ShouldContain("Sign Up for Your New Account"); + } + + [Fact] + public void CreateUserWizard_RendersUserNameField() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_UserName").ShouldNotBeNull(); + } + + [Fact] + public void CreateUserWizard_RendersPasswordFields() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_Password").ShouldNotBeNull(); + cut.Find("#cuw1_ConfirmPassword").ShouldNotBeNull(); + } + + [Fact] + public void CreateUserWizard_WithRequireEmail_RendersEmailField() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_Email").ShouldNotBeNull(); + } + + [Fact] + public void CreateUserWizard_WithAutoGeneratePassword_HidesPasswordFields() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + Assert.Throws(() => cut.Find("#cuw1_Password")); + Assert.Throws(() => cut.Find("#cuw1_ConfirmPassword")); + } + + [Fact] + public void CreateUserWizard_RendersCreateUserButton() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + var button = cut.Find("#cuw1_CreateUserButton"); + button.ShouldNotBeNull(); + button.GetAttribute("value").ShouldBe("Create User"); + } + + [Fact] + public void CreateUserWizard_WithDisplayCancelButton_RendersCancelButton() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_CancelButton").ShouldNotBeNull(); + } +} diff --git a/src/BlazorWebFormsComponents.Test/MultiView/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/MultiView/BasicFormat.razor new file mode 100644 index 00000000..3e7c4057 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/MultiView/BasicFormat.razor @@ -0,0 +1,50 @@ + +@code { + [Fact] + public void MultiView_WithActiveViewIndex0_RendersFirstView() + { + var cut = Render( + @ +

View One

+

View Two

+
+ ); + + cut.MarkupMatches(@

View One

); + } + + [Fact] + public void MultiView_WithActiveViewIndex1_RendersSecondView() + { + var cut = Render( + @ +

View One

+

View Two

+
+ ); + + cut.MarkupMatches(@

View Two

); + } + + [Fact] + public void MultiView_WithActiveViewIndexMinus1_RendersNothing() + { + var cut = Render( + @ +

View One

+

View Two

+
+ ); + + cut.MarkupMatches(string.Empty); + } + + [Fact] + public void MultiView_HasCommandNameConstants() + { + Assert.Equal("NextView", MultiView.NextViewCommandName); + Assert.Equal("PrevView", MultiView.PreviousViewCommandName); + Assert.Equal("SwitchViewByID", MultiView.SwitchViewByIDCommandName); + Assert.Equal("SwitchViewByIndex", MultiView.SwitchViewByIndexCommandName); + } +} diff --git a/src/BlazorWebFormsComponents/Calendar.razor b/src/BlazorWebFormsComponents/Calendar.razor new file mode 100644 index 00000000..c0e256b1 --- /dev/null +++ b/src/BlazorWebFormsComponents/Calendar.razor @@ -0,0 +1,86 @@ +@inherits BaseStyledComponent +@using BlazorWebFormsComponents.Enums + +@if (Visible) +{ + + @if (!string.IsNullOrEmpty(Caption)) + { + + } + @if (ShowTitle) + { + + @if (ShowNextPrevMonth && SelectionMode == CalendarSelectionMode.DayWeekMonth) + { + + } + @if (ShowNextPrevMonth) + { + + } + + @if (ShowNextPrevMonth) + { + + } + + } + @if (ShowDayHeader) + { + + @if (SelectionMode == CalendarSelectionMode.DayWeek || SelectionMode == CalendarSelectionMode.DayWeekMonth) + { + + } + @foreach (var day in GetDayHeaders()) + { + + } + + } + @foreach (var week in GetCalendarWeeks()) + { + + @if (SelectionMode == CalendarSelectionMode.DayWeek || SelectionMode == CalendarSelectionMode.DayWeekMonth) + { + + } + @foreach (var date in week) + { + var dayArgs = CreateDayRenderArgs(date); + + } + + } +
@Caption
+ @SelectMonthText + + @((MarkupString)PrevMonthText) + + @GetTitleText() + + @((MarkupString)NextMonthText) +
+ @GetDayName(day) +
+ @((MarkupString)SelectWeekText) + + @if (dayArgs.IsSelectable) + { + + @date.Day + + } + else + { + @date.Day + } +
+} diff --git a/src/BlazorWebFormsComponents/Calendar.razor.cs b/src/BlazorWebFormsComponents/Calendar.razor.cs new file mode 100644 index 00000000..f36883c0 --- /dev/null +++ b/src/BlazorWebFormsComponents/Calendar.razor.cs @@ -0,0 +1,467 @@ +using BlazorWebFormsComponents.Enums; +using Microsoft.AspNetCore.Components; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace BlazorWebFormsComponents +{ + public partial class Calendar : BaseStyledComponent + { + private DateTime _visibleMonth; + private readonly HashSet _selectedDays = new HashSet(); + private bool _initialized = false; + + public Calendar() + { + _visibleMonth = DateTime.Today; + SelectedDate = DateTime.MinValue; + } + + #region Properties + + /// + /// Gets or sets the selected date. + /// + [Parameter] + public DateTime SelectedDate { get; set; } + + /// + /// Event raised when the selection changes. + /// + [Parameter] + public EventCallback SelectedDateChanged { get; set; } + + /// + /// Gets the collection of selected dates for multi-selection. + /// + public IReadOnlyCollection SelectedDates => _selectedDays.ToList().AsReadOnly(); + + /// + /// Gets or sets the month to display. + /// + [Parameter] + public DateTime VisibleDate + { + get => _visibleMonth; + set + { + if (_visibleMonth != value) + { + var oldDate = _visibleMonth; + _visibleMonth = value; + if (_initialized && OnVisibleMonthChanged.HasDelegate) + { + _ = OnVisibleMonthChanged.InvokeAsync(new CalendarMonthChangedArgs + { + CurrentMonth = value, + PreviousMonth = oldDate + }); + } + } + } + } + + /// + /// Gets or sets the selection mode of the Calendar control. + /// + [Parameter] + public CalendarSelectionMode SelectionMode { get; set; } = CalendarSelectionMode.Day; + + /// + /// Gets or sets the text displayed as the caption of the calendar table. + /// + [Parameter] + public string Caption { get; set; } + + /// + /// Gets or sets the alignment of the caption relative to the calendar table. + /// + [Parameter] + public TableCaptionAlign CaptionAlign { get; set; } = TableCaptionAlign.NotSet; + + /// + /// Gets or sets whether the calendar renders accessible table headers using scope attributes. + /// + [Parameter] + public bool UseAccessibleHeader { get; set; } = true; + + /// + /// Event raised when a day is rendered, allowing customization. + /// + [Parameter] + public EventCallback OnDayRender { get; set; } + + /// + /// Event raised when the selection changes. + /// + [Parameter] + public EventCallback OnSelectionChanged { get; set; } + + /// + /// Event raised when the visible month changes. + /// + [Parameter] + public EventCallback OnVisibleMonthChanged { get; set; } + + /// + /// Shows or hides the title section. + /// + [Parameter] + public bool ShowTitle { get; set; } = true; + + /// + /// Shows or hides grid lines around days. + /// + [Parameter] + public bool ShowGridLines { get; set; } = false; + + /// + /// Shows or hides the day names row. + /// + [Parameter] + public bool ShowDayHeader { get; set; } = true; + + /// + /// Shows or hides next/previous month navigation. + /// + [Parameter] + public bool ShowNextPrevMonth { get; set; } = true; + + /// + /// Format for displaying day names. + /// + [Parameter] + public string DayNameFormat { get; set; } = "Short"; + + /// + /// Format for the title. + /// + [Parameter] + public string TitleFormat { get; set; } = "MonthYear"; + + /// + /// Text for next month link. + /// + [Parameter] + public string NextMonthText { get; set; } = ">"; + + /// + /// Text for previous month link. + /// + [Parameter] + public string PrevMonthText { get; set; } = "<"; + + /// + /// Text for selecting the entire week. + /// + [Parameter] + public string SelectWeekText { get; set; } = ">>"; + + /// + /// Text for selecting the entire month. + /// + [Parameter] + public string SelectMonthText { get; set; } = ">>"; + + /// + /// First day of the week. + /// + [Parameter] + public DayOfWeek FirstDayOfWeek { get; set; } = DayOfWeek.Sunday; + + /// + /// Cell padding for the table. + /// + [Parameter] + public int CellPadding { get; set; } = 2; + + /// + /// Cell spacing for the table. + /// + [Parameter] + public int CellSpacing { get; set; } = 0; + + /// + /// Tooltip text. + /// + [Parameter] + public string ToolTip { get; set; } + + // Style properties for different day types + [Parameter] + public string TitleStyleCss { get; set; } + + [Parameter] + public string DayHeaderStyleCss { get; set; } + + [Parameter] + public string DayStyleCss { get; set; } + + [Parameter] + public string TodayDayStyleCss { get; set; } + + [Parameter] + public string SelectedDayStyleCss { get; set; } + + [Parameter] + public string OtherMonthDayStyleCss { get; set; } + + [Parameter] + public string WeekendDayStyleCss { get; set; } + + [Parameter] + public string NextPrevStyleCss { get; set; } + + [Parameter] + public string SelectorStyleCss { get; set; } + + #endregion + + protected override void OnInitialized() + { + base.OnInitialized(); + _initialized = true; + + // Initialize selection if SelectedDate was provided + if (SelectedDate != DateTime.MinValue) + { + _selectedDays.Add(SelectedDate.Date); + } + } + + private string GetTableStyle() + { + var baseStyle = Style ?? ""; + if (ShowGridLines) + { + baseStyle += "border-collapse:collapse;"; + } + return string.IsNullOrWhiteSpace(baseStyle) ? null : baseStyle; + } + + private string GetBorder() + { + return ShowGridLines ? "1" : null; + } + + private string GetTitleText() + { + var format = TitleFormat == "Month" ? "MMMM" : "MMMM yyyy"; + return _visibleMonth.ToString(format, CultureInfo.CurrentCulture); + } + + private List GetDayHeaders() + { + var days = new List(); + var current = FirstDayOfWeek; + for (var i = 0; i < 7; i++) + { + days.Add(current); + current = (DayOfWeek)(((int)current + 1) % 7); + } + return days; + } + + private string GetDayName(DayOfWeek day) + { + return DayNameFormat switch + { + "Full" => CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), + "FirstLetter" => SafeSubstring(CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), 0, 1), + "FirstTwoLetters" => SafeSubstring(CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), 0, 2), + "Shortest" => CultureInfo.CurrentCulture.DateTimeFormat.GetShortestDayName(day), + _ => CultureInfo.CurrentCulture.DateTimeFormat.GetAbbreviatedDayName(day) + }; + } + + private static string SafeSubstring(string str, int start, int length) + { + if (string.IsNullOrEmpty(str) || start >= str.Length) + return str; + + return str.Substring(start, Math.Min(length, str.Length - start)); + } + + private List> GetCalendarWeeks() + { + var weeks = new List>(); + var firstOfMonth = new DateTime(_visibleMonth.Year, _visibleMonth.Month, 1); + + // Find the first day to display (may be from previous month) + var startDate = firstOfMonth; + while (startDate.DayOfWeek != FirstDayOfWeek) + { + startDate = startDate.AddDays(-1); + } + + // Build 6 weeks of days + for (var week = 0; week < 6; week++) + { + var weekDays = new List(); + for (var day = 0; day < 7; day++) + { + weekDays.Add(startDate); + startDate = startDate.AddDays(1); + } + weeks.Add(weekDays); + } + + return weeks; + } + + private async Task HandlePreviousMonth() + { + VisibleDate = _visibleMonth.AddMonths(-1); + await InvokeAsync(StateHasChanged); + } + + private async Task HandleNextMonth() + { + VisibleDate = _visibleMonth.AddMonths(1); + await InvokeAsync(StateHasChanged); + } + + private async Task HandleDayClick(DateTime date) + { + if (SelectionMode == CalendarSelectionMode.None) return; + + _selectedDays.Clear(); + _selectedDays.Add(date.Date); + SelectedDate = date.Date; + + if (SelectedDateChanged.HasDelegate) + { + await SelectedDateChanged.InvokeAsync(date.Date); + } + + if (OnSelectionChanged.HasDelegate) + { + await OnSelectionChanged.InvokeAsync(); + } + + await InvokeAsync(StateHasChanged); + } + + private async Task HandleWeekClick(List week) + { + if (SelectionMode != CalendarSelectionMode.DayWeek && SelectionMode != CalendarSelectionMode.DayWeekMonth) return; + + _selectedDays.Clear(); + foreach (var day in week) + { + _selectedDays.Add(day.Date); + } + SelectedDate = week[0].Date; + + if (SelectedDateChanged.HasDelegate) + { + await SelectedDateChanged.InvokeAsync(week[0].Date); + } + + if (OnSelectionChanged.HasDelegate) + { + await OnSelectionChanged.InvokeAsync(); + } + + await InvokeAsync(StateHasChanged); + } + + private async Task HandleMonthClick() + { + if (SelectionMode != CalendarSelectionMode.DayWeekMonth) return; + + _selectedDays.Clear(); + var firstOfMonth = new DateTime(_visibleMonth.Year, _visibleMonth.Month, 1); + var lastOfMonth = firstOfMonth.AddMonths(1).AddDays(-1); + + for (var d = firstOfMonth; d <= lastOfMonth; d = d.AddDays(1)) + { + _selectedDays.Add(d.Date); + } + SelectedDate = firstOfMonth.Date; + + if (SelectedDateChanged.HasDelegate) + { + await SelectedDateChanged.InvokeAsync(firstOfMonth.Date); + } + + if (OnSelectionChanged.HasDelegate) + { + await OnSelectionChanged.InvokeAsync(); + } + + await InvokeAsync(StateHasChanged); + } + + private string GetDayCellCss(DateTime date) + { + var isToday = date.Date == DateTime.Today; + var isSelected = _selectedDays.Contains(date.Date); + var isOtherMonth = date.Month != _visibleMonth.Month; + var isWeekend = date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday; + + // Priority order for styling + if (isSelected && !string.IsNullOrEmpty(SelectedDayStyleCss)) + return SelectedDayStyleCss; + if (isToday && !string.IsNullOrEmpty(TodayDayStyleCss)) + return TodayDayStyleCss; + if (isOtherMonth && !string.IsNullOrEmpty(OtherMonthDayStyleCss)) + return OtherMonthDayStyleCss; + if (isWeekend && !string.IsNullOrEmpty(WeekendDayStyleCss)) + return WeekendDayStyleCss; + + return DayStyleCss; + } + + /// + /// Creates day render arguments and invokes the OnDayRender event. + /// Note: OnDayRender is invoked synchronously during rendering. Handlers should not perform async operations or modify component state directly. + /// + private CalendarDayRenderArgs CreateDayRenderArgs(DateTime date) + { + var args = new CalendarDayRenderArgs + { + Date = date, + IsSelectable = SelectionMode != CalendarSelectionMode.None, + IsWeekend = date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday, + IsToday = date.Date == DateTime.Today, + IsOtherMonth = date.Month != _visibleMonth.Month, + IsSelected = _selectedDays.Contains(date.Date) + }; + + // Fire-and-forget: the handler can modify args properties synchronously + // before InvokeAsync yields. Avoid blocking with GetAwaiter().GetResult(). + if (OnDayRender.HasDelegate) + { + _ = OnDayRender.InvokeAsync(args); + } + + return args; + } + } + + /// + /// Event arguments for day rendering events. + /// + public class CalendarDayRenderArgs : EventArgs + { + public DateTime Date { get; set; } + public bool IsSelectable { get; set; } + public bool IsWeekend { get; set; } + public bool IsToday { get; set; } + public bool IsOtherMonth { get; set; } + public bool IsSelected { get; set; } + } + + /// + /// Event arguments for month change events. + /// + public class CalendarMonthChangedArgs : EventArgs + { + public DateTime CurrentMonth { get; set; } + public DateTime PreviousMonth { get; set; } + } +} diff --git a/src/BlazorWebFormsComponents/Enums/CalendarSelectionMode.cs b/src/BlazorWebFormsComponents/Enums/CalendarSelectionMode.cs new file mode 100644 index 00000000..18ea8063 --- /dev/null +++ b/src/BlazorWebFormsComponents/Enums/CalendarSelectionMode.cs @@ -0,0 +1,28 @@ +namespace BlazorWebFormsComponents.Enums +{ + /// + /// Specifies the selection mode of the Calendar control. + /// + public enum CalendarSelectionMode + { + /// + /// No dates can be selected. + /// + None = 0, + + /// + /// A single date can be selected. + /// + Day = 1, + + /// + /// A single date or an entire week can be selected. + /// + DayWeek = 2, + + /// + /// A single date, an entire week, or an entire month can be selected. + /// + DayWeekMonth = 3 + } +} diff --git a/src/BlazorWebFormsComponents/Localize.cs b/src/BlazorWebFormsComponents/Localize.cs new file mode 100644 index 00000000..a633e48f --- /dev/null +++ b/src/BlazorWebFormsComponents/Localize.cs @@ -0,0 +1,8 @@ +namespace BlazorWebFormsComponents; + +/// +/// Emulates the ASP.NET Web Forms Localize control. +/// Functionally identical to Literal — exists for markup compatibility. +/// In Blazor, pass localized strings via IStringLocalizer to the Text property. +/// +public class Localize : Literal { } diff --git a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor new file mode 100644 index 00000000..d6c83dea --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor @@ -0,0 +1,201 @@ +@inherits BaseWebFormsComponent + +@using BlazorWebFormsComponents.Validations; +@using Microsoft.AspNetCore.Components.Forms; +@using BlazorWebFormsComponents.Enums; + + + + + + + + + + + + @ChildContent + + + + + + + + + + @if (!ShowSuccessView) + { + @if (ChangePasswordTemplate != null) + { + @ChangePasswordTemplate + } + else + { + + + + + + +
+ + + @if (!string.IsNullOrEmpty(ChangePasswordTitleText)) + { + + + + } + @if (!string.IsNullOrEmpty(InstructionText)) + { + + + + } + @if (DisplayUserName) + { + + + + + } + + + + + + + + + + + + + @if (!string.IsNullOrEmpty(PasswordHintText)) + { + + + + } + @if (ShowFailureText) + { + + + + } + + + + @if (HasCreateUser || HasPasswordRecovery || HasHelp || HasEditProfile) + { + + + + } + +
@ChangePasswordTitleText
@InstructionText
+ + + +
+ + + +
+ + + +
+ + + +
@PasswordHintText
+ @ChangePasswordFailureText +
+ + +
+ @if (!string.IsNullOrEmpty(CreateUserIconUrl)) + { + @CreateUserText + } + @if (!string.IsNullOrEmpty(CreateUserText)) + { + @CreateUserText + } + @if (HasCreateUser && (HasPasswordRecovery || HasHelp || HasEditProfile)) + { +
+ } + @if (!string.IsNullOrEmpty(PasswordRecoveryIconUrl)) + { + @PasswordRecoveryText + } + @if (!string.IsNullOrEmpty(PasswordRecoveryText)) + { + @PasswordRecoveryText + } + @if (HasPasswordRecovery && (HasHelp || HasEditProfile)) + { +
+ } + @if (!string.IsNullOrEmpty(HelpPageIconUrl)) + { + @HelpPageText + } + @if (!string.IsNullOrEmpty(HelpPageText)) + { + @HelpPageText + } + @if (HasHelp && HasEditProfile) + { +
+ } + @if (!string.IsNullOrEmpty(EditProfileIconUrl)) + { + @EditProfileText + } + @if (!string.IsNullOrEmpty(EditProfileText)) + { + @EditProfileText + } +
+
+ } + } + else + { + @if (SuccessTemplate != null) + { + @SuccessTemplate + } + else + { + + + + + + +
+ + + + + + + + + + + + +
@SuccessTitleText
@SuccessText
+ +
+
+ } + } + +
diff --git a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs new file mode 100644 index 00000000..e202858f --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs @@ -0,0 +1,252 @@ +using BlazorWebFormsComponents.Enums; +using BlazorWebFormsComponents.Validations; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using System; +using System.Threading.Tasks; + +namespace BlazorWebFormsComponents.LoginControls +{ + public partial class ChangePassword : BaseWebFormsComponent + { + #region Obsolete + + [Parameter, Obsolete("MembershipProvider not supported in Blazor")] + public string MembershipProvider { get; set; } + + #endregion + + #region Button Properties + + [Parameter] public string CancelButtonImageUrl { get; set; } + [Parameter] public string CancelButtonText { get; set; } = "Cancel"; + [Parameter] public ButtonType CancelButtonType { get; set; } = ButtonType.Button; + [Parameter] public string CancelDestinationPageUrl { get; set; } + + [Parameter] public string ChangePasswordButtonImageUrl { get; set; } + [Parameter] public string ChangePasswordButtonText { get; set; } = "Change Password"; + [Parameter] public ButtonType ChangePasswordButtonType { get; set; } = ButtonType.Button; + + [Parameter] public string ContinueButtonImageUrl { get; set; } + [Parameter] public string ContinueButtonText { get; set; } = "Continue"; + [Parameter] public ButtonType ContinueButtonType { get; set; } = ButtonType.Button; + [Parameter] public string ContinueDestinationPageUrl { get; set; } + + #endregion + + #region Text Properties + + [Parameter] public string ChangePasswordTitleText { get; set; } = "Change Your Password"; + [Parameter] public string ConfirmNewPasswordLabelText { get; set; } = "Confirm New Password:"; + [Parameter] public string ConfirmPasswordCompareErrorMessage { get; set; } = "The Confirm New Password must match the New Password entry."; + [Parameter] public string ConfirmPasswordRequiredErrorMessage { get; set; } = "Confirm New Password is required."; + [Parameter] public string ChangePasswordFailureText { get; set; } = "Password incorrect or New Password invalid."; + [Parameter] public string InstructionText { get; set; } + [Parameter] public string NewPasswordLabelText { get; set; } = "New Password:"; + [Parameter] public string NewPasswordRegularExpression { get; set; } + [Parameter] public string NewPasswordRegularExpressionErrorMessage { get; set; } + [Parameter] public string NewPasswordRequiredErrorMessage { get; set; } = "New Password is required."; + [Parameter] public string PasswordHintText { get; set; } + [Parameter] public string PasswordLabelText { get; set; } = "Password:"; + [Parameter] public string PasswordRequiredErrorMessage { get; set; } = "Password is required."; + [Parameter] public string SuccessPageUrl { get; set; } + [Parameter] public string SuccessText { get; set; } = "Your password has been changed!"; + [Parameter] public string SuccessTitleText { get; set; } = "Change Password Complete"; + + #endregion + + #region User Properties + + [Parameter] public bool DisplayUserName { get; set; } + [Parameter] public string UserName { get => Model?.UserName ?? string.Empty; set { if (Model != null) Model.UserName = value; } } + [Parameter] public string UserNameLabelText { get; set; } = "User Name:"; + [Parameter] public string UserNameRequiredErrorMessage { get; set; } = "User Name is required."; + + #endregion + + #region Link Properties + + [Parameter] public string CreateUserIconUrl { get; set; } + [Parameter] public string CreateUserText { get; set; } + [Parameter] public string CreateUserUrl { get; set; } + [Parameter] public string EditProfileIconUrl { get; set; } + [Parameter] public string EditProfileText { get; set; } + [Parameter] public string EditProfileUrl { get; set; } + [Parameter] public string HelpPageIconUrl { get; set; } + [Parameter] public string HelpPageText { get; set; } + [Parameter] public string HelpPageUrl { get; set; } + [Parameter] public string PasswordRecoveryIconUrl { get; set; } + [Parameter] public string PasswordRecoveryText { get; set; } + [Parameter] public string PasswordRecoveryUrl { get; set; } + + #endregion + + #region Layout Properties + + [Parameter] public int BorderPadding { get; set; } = 1; + [Parameter] public bool RenderOuterTable { get; set; } = true; + + #endregion + + #region Events + + [Parameter] public EventCallback OnCancelButtonClick { get; set; } + [Parameter] public EventCallback OnChangedPassword { get; set; } + [Parameter] public EventCallback OnChangePasswordError { get; set; } + [Parameter] public EventCallback OnChangingPassword { get; set; } + [Parameter] public EventCallback OnContinueButtonClick { get; set; } + + #endregion + + #region Templates + + [Parameter] public RenderFragment ChildContent { get; set; } + [Parameter] public RenderFragment ChangePasswordTemplate { get; set; } + [Parameter] public RenderFragment SuccessTemplate { get; set; } + + #endregion + + #region Style + + [CascadingParameter(Name = "FailureTextStyle")] + private TableItemStyle FailureTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TitleTextStyle")] + private TableItemStyle TitleTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "LabelStyle")] + private TableItemStyle LabelStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "InstructionTextStyle")] + private TableItemStyle InstructionTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TextBoxStyle")] + private Style TextBoxStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "LoginButtonStyle")] + private Style LoginButtonStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "ValidatorTextStyle")] + private Style ValidatorTextStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "HyperLinkStyle")] + private TableItemStyle HyperLinkStyle { get; set; } = new TableItemStyle(); + + #endregion + + #region Services + + [Inject] + protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } + + [Inject] + protected NavigationManager NavigationManager { get; set; } + + #endregion + + #region Internal State + + private ChangePasswordModel Model { get; set; } + private bool ShowSuccessView { get; set; } + private bool ShowFailureText { get; set; } + + private bool HasHelp => !string.IsNullOrEmpty(HelpPageText) || !string.IsNullOrEmpty(HelpPageIconUrl); + private bool HasPasswordRecovery => !string.IsNullOrEmpty(PasswordRecoveryText) || !string.IsNullOrEmpty(PasswordRecoveryIconUrl); + private bool HasCreateUser => !string.IsNullOrEmpty(CreateUserText) || !string.IsNullOrEmpty(CreateUserIconUrl); + private bool HasEditProfile => !string.IsNullOrEmpty(EditProfileText) || !string.IsNullOrEmpty(EditProfileIconUrl); + + public string CurrentPassword { get => Model?.CurrentPassword ?? string.Empty; set { if (Model != null) Model.CurrentPassword = value; } } + public string NewPassword { get => Model?.NewPassword ?? string.Empty; set { if (Model != null) Model.NewPassword = value; } } + + #endregion + + protected override async Task OnInitializedAsync() + { + Model = new ChangePasswordModel(); + + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + if (!DisplayUserName && authState.User?.Identity?.IsAuthenticated == true) + { + Model.UserName = authState.User.Identity.Name ?? string.Empty; + } + + await base.OnInitializedAsync(); + } + + private async Task HandleValidSubmit() + { + var cancelArgs = new LoginCancelEventArgs { Sender = this }; + await OnChangingPassword.InvokeAsync(cancelArgs); + + if (cancelArgs.Cancel) + { + Model.CurrentPassword = string.Empty; + Model.NewPassword = string.Empty; + Model.ConfirmNewPassword = string.Empty; + return; + } + + // The developer must handle OnChangingPassword to do the actual password change. + // If they didn't cancel, we consider it successful. + ShowFailureText = false; + ShowSuccessView = true; + await OnChangedPassword.InvokeAsync(EventArgs.Empty); + } + + internal async Task HandleChangePasswordError() + { + ShowFailureText = true; + Model.CurrentPassword = string.Empty; + Model.NewPassword = string.Empty; + Model.ConfirmNewPassword = string.Empty; + await OnChangePasswordError.InvokeAsync(EventArgs.Empty); + } + + private async Task HandleCancelClick() + { + await OnCancelButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(CancelDestinationPageUrl)) + { + NavigationManager.NavigateTo(CancelDestinationPageUrl); + } + } + + private async Task HandleContinueClick() + { + await OnContinueButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(ContinueDestinationPageUrl)) + { + NavigationManager.NavigateTo(ContinueDestinationPageUrl); + } + else if (!string.IsNullOrEmpty(SuccessPageUrl)) + { + NavigationManager.NavigateTo(SuccessPageUrl); + } + } + + protected override void HandleUnknownAttributes() + { + if (AdditionalAttributes?.Count > 0) + { + FailureTextStyle.FromUnknownAttributes(AdditionalAttributes, "FailureTextStyle-"); + TitleTextStyle.FromUnknownAttributes(AdditionalAttributes, "TitleTextStyle-"); + LabelStyle.FromUnknownAttributes(AdditionalAttributes, "LabelStyle-"); + InstructionTextStyle.FromUnknownAttributes(AdditionalAttributes, "InstructionTextStyle-"); + TextBoxStyle.FromUnknownAttributes(AdditionalAttributes, "TextBoxStyle-"); + LoginButtonStyle.FromUnknownAttributes(AdditionalAttributes, "LoginButtonStyle-"); + ValidatorTextStyle.FromUnknownAttributes(AdditionalAttributes, "ValidatorTextStyle-"); + HyperLinkStyle.FromUnknownAttributes(AdditionalAttributes, "HyperLinkStyle-"); + } + + base.HandleUnknownAttributes(); + } + + public class ChangePasswordModel + { + public string UserName { get; set; } = string.Empty; + public string CurrentPassword { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ConfirmNewPassword { get; set; } = string.Empty; + } + } +} diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserErrorEventArgs.cs b/src/BlazorWebFormsComponents/LoginControls/CreateUserErrorEventArgs.cs new file mode 100644 index 00000000..1bd45163 --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserErrorEventArgs.cs @@ -0,0 +1,17 @@ +using System; + +namespace BlazorWebFormsComponents.LoginControls +{ + /// + /// Provides data for the CreateUserError event of CreateUserWizard. + /// + public class CreateUserErrorEventArgs : EventArgs + { + public string ErrorMessage { get; set; } + + /// + /// The component that raised this event. + /// + public object Sender { get; set; } + } +} diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor new file mode 100644 index 00000000..f097e01c --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor @@ -0,0 +1,217 @@ +@inherits BaseWebFormsComponent + +@using BlazorWebFormsComponents.Validations; +@using Microsoft.AspNetCore.Components.Forms; +@using BlazorWebFormsComponents.Enums; + + + + + + + + + + + + @ChildContent + + + + + + + + + + @if (!ShowCompleteStep) + { + @if (CreateUserStep != null) + { + @CreateUserStep + } + else + { + + + + @if (DisplaySideBar) + { + + } + + + +
+ @if (SideBarTemplate != null) + { + @SideBarTemplate + } + else + { + Create User
+ Complete + } +
+ @if (HeaderTemplate != null) + { + @HeaderTemplate + } + + + + + + @if (!string.IsNullOrEmpty(InstructionText)) + { + + + + } + + + + + @if (!AutoGeneratePassword) + { + + + + + + + + + } + @if (RequireEmail) + { + + + + + } + @if (ShowSecurityQuestion) + { + + + + + + + + + } + @if (!string.IsNullOrEmpty(PasswordHintText)) + { + + + + } + @if (ShowFailureText) + { + + + + } + + + + @if (HasHelp || HasEditProfile) + { + + + + } + +
Sign Up for Your New Account
@InstructionText
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
@PasswordHintText
+ @FailureText +
+ + @if (DisplayCancelButton) + { + + } +
+ @if (!string.IsNullOrEmpty(HelpPageIconUrl)) + { + @HelpPageText + } + @if (!string.IsNullOrEmpty(HelpPageText)) + { + @HelpPageText + } + @if (HasHelp && HasEditProfile) + { +
+ } + @if (!string.IsNullOrEmpty(EditProfileIconUrl)) + { + @EditProfileText + } + @if (!string.IsNullOrEmpty(EditProfileText)) + { + @EditProfileText + } +
+
+ } + } + else + { + @if (CompleteStep != null) + { + @CompleteStep + } + else + { + + + + + + +
+ + + + + + + + + + + + +
Complete
@CompleteSuccessText
+ +
+
+ } + } + +
diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs new file mode 100644 index 00000000..3d4edd46 --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs @@ -0,0 +1,257 @@ +using BlazorWebFormsComponents.Enums; +using BlazorWebFormsComponents.Validations; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using System; +using System.Threading.Tasks; + +namespace BlazorWebFormsComponents.LoginControls +{ + public partial class CreateUserWizard : BaseWebFormsComponent + { + #region Obsolete + + [Parameter, Obsolete("MembershipProvider not supported in Blazor")] + public string MembershipProvider { get; set; } + + #endregion + + #region User Properties + + [Parameter] public string UserName { get => Model?.UserName ?? string.Empty; set { if (Model != null) Model.UserName = value; } } + [Parameter] public string UserNameLabelText { get; set; } = "User Name:"; + [Parameter] public string UserNameRequiredErrorMessage { get; set; } = "User Name is required."; + [Parameter] public string Password { get => Model?.Password ?? string.Empty; set { if (Model != null) Model.Password = value; } } + [Parameter] public string PasswordLabelText { get; set; } = "Password:"; + [Parameter] public string PasswordRequiredErrorMessage { get; set; } = "Password is required."; + [Parameter] public string PasswordHintText { get; set; } + [Parameter] public string PasswordRegularExpression { get; set; } + [Parameter] public string PasswordRegularExpressionErrorMessage { get; set; } + [Parameter] public string ConfirmPasswordLabelText { get; set; } = "Confirm Password:"; + [Parameter] public string ConfirmPasswordCompareErrorMessage { get; set; } = "The Password and Confirmation Password must match."; + [Parameter] public string ConfirmPasswordRequiredErrorMessage { get; set; } = "Confirm Password is required."; + [Parameter] public string Email { get => Model?.Email ?? string.Empty; set { if (Model != null) Model.Email = value; } } + [Parameter] public string EmailLabelText { get; set; } = "E-mail:"; + [Parameter] public string EmailRequiredErrorMessage { get; set; } = "E-mail is required."; + [Parameter] public string EmailRegularExpression { get; set; } + [Parameter] public string EmailRegularExpressionErrorMessage { get; set; } + [Parameter] public string Question { get => Model?.Question ?? string.Empty; set { if (Model != null) Model.Question = value; } } + [Parameter] public string QuestionLabelText { get; set; } = "Security Question:"; + [Parameter] public string QuestionRequiredErrorMessage { get; set; } = "Security question is required."; + [Parameter] public string Answer { get => Model?.Answer ?? string.Empty; set { if (Model != null) Model.Answer = value; } } + [Parameter] public string AnswerLabelText { get; set; } = "Security Answer:"; + [Parameter] public string AnswerRequiredErrorMessage { get; set; } = "Security answer is required."; + [Parameter] public bool RequireEmail { get; set; } = true; + [Parameter] public bool AutoGeneratePassword { get; set; } + [Parameter] public bool DisableCreatedUser { get; set; } + [Parameter] public bool LoginCreatedUser { get; set; } = true; + + #endregion + + #region Button Properties + + [Parameter] public string CancelButtonImageUrl { get; set; } + [Parameter] public string CancelButtonText { get; set; } = "Cancel"; + [Parameter] public ButtonType CancelButtonType { get; set; } = ButtonType.Button; + [Parameter] public string CancelDestinationPageUrl { get; set; } + [Parameter] public bool DisplayCancelButton { get; set; } + + [Parameter] public string CreateUserButtonImageUrl { get; set; } + [Parameter] public string CreateUserButtonText { get; set; } = "Create User"; + [Parameter] public ButtonType CreateUserButtonType { get; set; } = ButtonType.Button; + + [Parameter] public string ContinueButtonImageUrl { get; set; } + [Parameter] public string ContinueButtonText { get; set; } = "Continue"; + [Parameter] public ButtonType ContinueButtonType { get; set; } = ButtonType.Button; + [Parameter] public string ContinueDestinationPageUrl { get; set; } + + #endregion + + #region Text Properties + + [Parameter] public string CompleteSuccessText { get; set; } = "Your account has been successfully created."; + [Parameter] public string InstructionText { get; set; } + [Parameter] public string DuplicateEmailErrorMessage { get; set; } = "The e-mail address that you entered is already in use. Please enter a different e-mail address."; + [Parameter] public string DuplicateUserNameErrorMessage { get; set; } = "Please enter a different user name."; + [Parameter] public string InvalidAnswerErrorMessage { get; set; } = "Please enter a different security answer."; + [Parameter] public string InvalidEmailErrorMessage { get; set; } = "Please enter a valid e-mail address."; + [Parameter] public string InvalidPasswordErrorMessage { get; set; } = "Password length minimum: {0}. Non-alphanumeric characters required: {1}."; + [Parameter] public string InvalidQuestionErrorMessage { get; set; } = "Please enter a different security question."; + [Parameter] public string UnknownErrorMessage { get; set; } = "Your account was not created. Please try again."; + + #endregion + + #region Link Properties + + [Parameter] public string EditProfileIconUrl { get; set; } + [Parameter] public string EditProfileText { get; set; } + [Parameter] public string EditProfileUrl { get; set; } + [Parameter] public string HelpPageIconUrl { get; set; } + [Parameter] public string HelpPageText { get; set; } + [Parameter] public string HelpPageUrl { get; set; } + + #endregion + + #region Layout Properties + + [Parameter] public int ActiveStepIndex { get; set; } + [Parameter] public int BorderPadding { get; set; } = 1; + [Parameter] public bool RenderOuterTable { get; set; } = true; + [Parameter] public bool DisplaySideBar { get; set; } = true; + + #endregion + + #region Events + + [Parameter] public EventCallback OnCreatingUser { get; set; } + [Parameter] public EventCallback OnCreatedUser { get; set; } + [Parameter] public EventCallback OnCreateUserError { get; set; } + [Parameter] public EventCallback OnCancelButtonClick { get; set; } + [Parameter] public EventCallback OnContinueButtonClick { get; set; } + [Parameter] public EventCallback OnActiveStepChanged { get; set; } + [Parameter] public EventCallback OnNextButtonClick { get; set; } + [Parameter] public EventCallback OnPreviousButtonClick { get; set; } + [Parameter] public EventCallback OnFinishButtonClick { get; set; } + + #endregion + + #region Templates + + [Parameter] public RenderFragment ChildContent { get; set; } + [Parameter] public RenderFragment CreateUserStep { get; set; } + [Parameter] public RenderFragment CompleteStep { get; set; } + [Parameter] public RenderFragment SideBarTemplate { get; set; } + [Parameter] public RenderFragment HeaderTemplate { get; set; } + + #endregion + + #region Style + + [CascadingParameter(Name = "FailureTextStyle")] + private TableItemStyle FailureTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TitleTextStyle")] + private TableItemStyle TitleTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "LabelStyle")] + private TableItemStyle LabelStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "InstructionTextStyle")] + private TableItemStyle InstructionTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TextBoxStyle")] + private Style TextBoxStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "LoginButtonStyle")] + private Style LoginButtonStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "ValidatorTextStyle")] + private Style ValidatorTextStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "HyperLinkStyle")] + private TableItemStyle HyperLinkStyle { get; set; } = new TableItemStyle(); + + #endregion + + #region Services + + [Inject] + protected NavigationManager NavigationManager { get; set; } + + #endregion + + #region Internal State + + private CreateUserModel Model { get; set; } + private bool ShowCompleteStep { get; set; } + private bool ShowFailureText { get; set; } + private string FailureText { get; set; } + + private bool HasHelp => !string.IsNullOrEmpty(HelpPageText) || !string.IsNullOrEmpty(HelpPageIconUrl); + private bool HasEditProfile => !string.IsNullOrEmpty(EditProfileText) || !string.IsNullOrEmpty(EditProfileIconUrl); + private bool ShowSecurityQuestion => !string.IsNullOrEmpty(Model?.Question); + + #endregion + + protected override async Task OnInitializedAsync() + { + Model = new CreateUserModel(); + await base.OnInitializedAsync(); + } + + private async Task HandleValidSubmit() + { + var cancelArgs = new LoginCancelEventArgs { Sender = this }; + await OnCreatingUser.InvokeAsync(cancelArgs); + + if (cancelArgs.Cancel) + { + return; + } + + // Developer handles OnCreatingUser to perform actual user creation. + // If not cancelled, we consider it successful. + ShowFailureText = false; + ShowCompleteStep = true; + ActiveStepIndex = 1; + await OnCreatedUser.InvokeAsync(EventArgs.Empty); + await OnActiveStepChanged.InvokeAsync(EventArgs.Empty); + } + + internal async Task HandleCreateUserError(string errorMessage) + { + ShowFailureText = true; + FailureText = errorMessage ?? UnknownErrorMessage; + await OnCreateUserError.InvokeAsync(new CreateUserErrorEventArgs + { + ErrorMessage = FailureText, + Sender = this + }); + } + + private async Task HandleCancelClick() + { + await OnCancelButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(CancelDestinationPageUrl)) + { + NavigationManager.NavigateTo(CancelDestinationPageUrl); + } + } + + private async Task HandleContinueClick() + { + await OnContinueButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(ContinueDestinationPageUrl)) + { + NavigationManager.NavigateTo(ContinueDestinationPageUrl); + } + } + + protected override void HandleUnknownAttributes() + { + if (AdditionalAttributes?.Count > 0) + { + FailureTextStyle.FromUnknownAttributes(AdditionalAttributes, "FailureTextStyle-"); + TitleTextStyle.FromUnknownAttributes(AdditionalAttributes, "TitleTextStyle-"); + LabelStyle.FromUnknownAttributes(AdditionalAttributes, "LabelStyle-"); + InstructionTextStyle.FromUnknownAttributes(AdditionalAttributes, "InstructionTextStyle-"); + TextBoxStyle.FromUnknownAttributes(AdditionalAttributes, "TextBoxStyle-"); + LoginButtonStyle.FromUnknownAttributes(AdditionalAttributes, "LoginButtonStyle-"); + ValidatorTextStyle.FromUnknownAttributes(AdditionalAttributes, "ValidatorTextStyle-"); + HyperLinkStyle.FromUnknownAttributes(AdditionalAttributes, "HyperLinkStyle-"); + } + + base.HandleUnknownAttributes(); + } + + public class CreateUserModel + { + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Question { get; set; } = string.Empty; + public string Answer { get; set; } = string.Empty; + } + } +} diff --git a/src/BlazorWebFormsComponents/MultiView.razor b/src/BlazorWebFormsComponents/MultiView.razor new file mode 100644 index 00000000..0ed49f37 --- /dev/null +++ b/src/BlazorWebFormsComponents/MultiView.razor @@ -0,0 +1,5 @@ +@inherits BaseWebFormsComponent + + + @ChildContent + diff --git a/src/BlazorWebFormsComponents/MultiView.razor.cs b/src/BlazorWebFormsComponents/MultiView.razor.cs new file mode 100644 index 00000000..4ce7f599 --- /dev/null +++ b/src/BlazorWebFormsComponents/MultiView.razor.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + public partial class MultiView : BaseWebFormsComponent + { + public const string NextViewCommandName = "NextView"; + public const string PreviousViewCommandName = "PrevView"; + public const string SwitchViewByIDCommandName = "SwitchViewByID"; + public const string SwitchViewByIndexCommandName = "SwitchViewByIndex"; + + private int _activeViewIndex = -1; + + [Parameter] + public int ActiveViewIndex + { + get => _activeViewIndex; + set + { + if (value < -1 || (Views.Count > 0 && value >= Views.Count)) + { + throw new ArgumentOutOfRangeException(nameof(ActiveViewIndex), + $"ActiveViewIndex is set to '{value}', which is out of range. Must be between -1 and {Views.Count - 1}."); + } + if (_activeViewIndex != value) + { + UpdateActiveView(_activeViewIndex, value); + _activeViewIndex = value; + } + } + } + + [Parameter] + public EventCallback OnActiveViewChanged { get; set; } + + [Parameter] + public RenderFragment ChildContent { get; set; } + + public List Views { get; } = new List(); + + public View GetActiveView() + { + if (_activeViewIndex < 0 || _activeViewIndex >= Views.Count) + { + throw new InvalidOperationException( + "The ActiveViewIndex is not set to a valid View control."); + } + return Views[_activeViewIndex]; + } + + public void SetActiveView(View view) + { + if (view == null) throw new ArgumentNullException(nameof(view)); + + var index = Views.IndexOf(view); + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(view), + "The specified View is not part of this MultiView."); + } + ActiveViewIndex = index; + } + + internal void RegisterView(View view) + { + if (!Views.Contains(view)) + { + Views.Add(view); + + var index = Views.IndexOf(view); + if (index == _activeViewIndex) + { + view.Visible = true; + view.NotifyActivated(); + } + else + { + view.Visible = false; + } + } + } + + private void UpdateActiveView(int oldIndex, int newIndex) + { + if (oldIndex >= 0 && oldIndex < Views.Count) + { + Views[oldIndex].Visible = false; + Views[oldIndex].NotifyDeactivated(); + } + + if (newIndex >= 0 && newIndex < Views.Count) + { + Views[newIndex].Visible = true; + Views[newIndex].NotifyActivated(); + } + + OnActiveViewChanged.InvokeAsync(EventArgs.Empty); + } + + protected override void OnBubbledEvent(object sender, EventArgs args) + { + base.OnBubbledEvent(sender, args); + } + } +} diff --git a/src/BlazorWebFormsComponents/View.razor b/src/BlazorWebFormsComponents/View.razor new file mode 100644 index 00000000..905fd765 --- /dev/null +++ b/src/BlazorWebFormsComponents/View.razor @@ -0,0 +1,6 @@ +@inherits BaseWebFormsComponent + +@if (Visible) +{ + @ChildContent +} diff --git a/src/BlazorWebFormsComponents/View.razor.cs b/src/BlazorWebFormsComponents/View.razor.cs new file mode 100644 index 00000000..4cc0d15d --- /dev/null +++ b/src/BlazorWebFormsComponents/View.razor.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + public partial class View : BaseWebFormsComponent + { + [CascadingParameter(Name = "ParentMultiView")] + public MultiView ParentMultiView { get; set; } + + [Parameter] + public RenderFragment ChildContent { get; set; } + + [Parameter] + public EventCallback OnActivate { get; set; } + + [Parameter] + public EventCallback OnDeactivate { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + ParentMultiView?.RegisterView(this); + } + + internal void NotifyActivated() + { + OnActivate.InvokeAsync(EventArgs.Empty); + } + + internal void NotifyDeactivated() + { + OnDeactivate.InvokeAsync(EventArgs.Empty); + } + } +} diff --git a/status.md b/status.md index b5d55381..35acaefa 100644 --- a/status.md +++ b/status.md @@ -2,12 +2,12 @@ | Category | Completed | In Progress | Not Started | Total | |----------|-----------|-------------|-------------|-------| -| Editor Controls | 18 | 0 | 9 | 27 | +| Editor Controls | 20 | 0 | 7 | 27 | | Data Controls | 7 | 0 | 2 | 9 | | Validation Controls | 7 | 0 | 0 | 7 | | Navigation Controls | 3 | 0 | 0 | 3 | -| Login Controls | 4 | 0 | 3 | 7 | -| **TOTAL** | **39** | **0** | **14** | **53** | +| Login Controls | 6 | 0 | 1 | 7 | +| **TOTAL** | **41** | **0** | **12** | **53** | --- @@ -36,14 +36,14 @@ | FileUpload | 🔴 Not Started | Consider Blazor InputFile | | ImageMap | ✅ Complete | Documented, tested (23 tests) | | ListBox | ✅ Complete | Documented, tested, supports single/multi-select | -| Localize | 🔴 Not Started | Localization control | -| MultiView | 🔴 Not Started | Tab container | +| Localize | ✅ Complete | Documented, tested, inherits from Literal | +| MultiView | ✅ Complete | Documented, tested, with View component | | Panel | ✅ Complete | Documented, tested | | PlaceHolder | ✅ Complete | Documented, tested - renders no wrapper element | | RadioButtonList | ✅ Complete | Documented, tested (30 tests) | | Substitution | 🔴 Not Started | Cache substitution - may not apply | | Table | ✅ Complete | Includes TableRow, TableCell, TableHeaderCell, TableHeaderRow, TableFooterRow | -| View | 🔴 Not Started | Used with MultiView | +| View | ✅ Complete | Used with MultiView | | Xml | 🔴 Not Started | XML display/transform | ### 🟡 Data Controls (7/9 - 78% Complete) @@ -89,8 +89,8 @@ | LoginName | ✅ Complete | Documented, tested, sample page exists | | LoginStatus | ✅ Complete | Documented, tested, sample pages exist | | LoginView | ✅ Complete | Documented, tested | -| ChangePassword | 🔴 Not Started | Complex ASP.NET Identity integration | -| CreateUserWizard | 🔴 Not Started | Complex - user registration wizard | +| ChangePassword | ✅ Complete | Documented, tested, table-based layout | +| CreateUserWizard | ✅ Complete | Documented, tested, two-step wizard | | PasswordRecovery | 🔴 Not Started | Complex ASP.NET Identity integration | ---