diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f6e9ad1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: TeachLink Community + url: https://t.me/teachlinkOD + about: Ask questions and discuss changes with maintainers. diff --git a/.github/ISSUE_TEMPLATE/quality-gates.yml b/.github/ISSUE_TEMPLATE/quality-gates.yml new file mode 100644 index 0000000..12991d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/quality-gates.yml @@ -0,0 +1,39 @@ +name: "PR Quality Gates / Governance" +description: "Work related to CI, branch protection, PR process, governance" +title: "[Governance] " +labels: ["frontend", "devops", "ci", "governance", "priority-high"] +body: + - type: markdown + attributes: + value: | + Use this template for branch protection, CI, and PR process improvements. + + - type: input + id: background + attributes: + label: Background + description: Why is this needed? + validations: + required: true + + - type: textarea + id: acceptance + attributes: + label: Acceptance Criteria + description: List the conditions that must be true to close this issue. + value: | + - [ ] PR cannot merge without passing CI + - [ ] PR cannot merge without approval + - [ ] Direct push to main/develop is blocked + - [ ] All PRs must reference an issue + - [ ] All conversations must be resolved + validations: + required: true + + - type: textarea + id: implementation + attributes: + label: Implementation notes + description: Optional implementation plan / links. + validations: + required: false diff --git a/.github/branch-protection.md b/.github/branch-protection.md new file mode 100644 index 0000000..959489a --- /dev/null +++ b/.github/branch-protection.md @@ -0,0 +1,49 @@ +# Branch protection (repository settings) + +These are **GitHub repository settings** (not enforced by code). Configure them under: +**Settings → Branches → Branch protection rules**. + +## Protected branches +Create rules for: +- `main` +- `develop` + +## Required settings +Enable the following options: + +### блок direct pushes +- ✅ **Restrict who can push to matching branches** (recommended) +- ✅ **Do not allow force pushes** +- ✅ **Do not allow deletions** + +### Require PRs +- ✅ **Require a pull request before merging** +- ✅ **Require approvals**: at least **1** (or **2** if desired) +- ✅ **Dismiss stale approvals when new commits are pushed** +- ✅ **Require conversation resolution before merging** + +### Require checks +- ✅ **Require status checks to pass before merging** +- ✅ Select required checks from `Frontend CI`: + - `type-check` + - `lint` + - `build` + - `test` + +### Keep branch up to date +- ✅ **Require branches to be up to date before merging** + +## PR must reference an issue +GitHub does not have a single built-in "require issue link" toggle for all repos. +Recommended options: + +1. **Process enforcement (lightweight)** + - Use `.github/pull_request_template.md` and require `Closes #` in the PR. + +2. **Stronger enforcement (recommended)** + - Add a dedicated GitHub Action that fails if the PR body does not contain `Closes #`. + - If you want this, we can add a small workflow using `actions/github-script`. + +## Notes +- Repository admins can optionally be included or excluded from these requirements. +- Configure the same rule set for both `main` and `develop` to avoid bypass paths. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..aaf3f2c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ +## Summary + + +## Related Issue + +Closes # + +## Type of change +- [ ] Feature +- [ ] Bug fix +- [ ] Chore / Refactor +- [ ] Docs + +## Screenshots / Recording (if UI) + + +## Testing + +- [ ] `npm run type-check` +- [ ] `npm run lint` +- [ ] `npm run test` +- [ ] `npm run build` + +## Quality gate checklist +- [ ] CI checks pass (Frontend CI) +- [ ] At least 1–2 approvals (per branch protection rules) +- [ ] Branch is up-to-date with the base branch +- [ ] All conversations resolved +- [ ] PR description includes `Closes #` diff --git a/.github/workflows/pr-quality-gates.yml b/.github/workflows/pr-quality-gates.yml new file mode 100644 index 0000000..7b98c39 --- /dev/null +++ b/.github/workflows/pr-quality-gates.yml @@ -0,0 +1,26 @@ +name: PR Quality Gates + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: [main, develop] + +permissions: + pull-requests: read + +jobs: + require-linked-issue: + name: Require linked issue in PR body + runs-on: ubuntu-latest + steps: + - name: Validate PR body contains issue closing keyword + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.pull_request?.body || ''; + // Accept common keywords: close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved + // Require a github issue reference like: "Closes #123" + const re = /(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#\d+/i; + if (!re.test(body)) { + core.setFailed('PR description must reference an issue using e.g. "Closes #123".'); + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a71783d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to TeachLink Frontend + +Thanks for contributing to TeachLink. + +## Branching & workflow +- **Do not push directly** to protected branches (`main`, `develop`). +- Create a feature branch from `develop` (preferred) or `main`: + - `feat/` + - `fix/` + - `chore/` + +## Assignment required +Before opening a PR, ensure the issue is assigned to you. + +## Pull request requirements (quality gates) +Your PR will be blocked from merging unless it meets the following: + +1. **CI must pass** + - Required checks: `type-check`, `lint`, `build`, `test` (GitHub Actions: **Frontend CI**) + +2. **Approvals required** + - Minimum **1–2 approvals** (as configured in branch protection rules). + +3. **Branch must be up to date** + - Update your branch with the target branch before merge (no stale merge). + +4. **Conversations resolved** + - All review conversations must be resolved before merge. + +5. **Issue must be referenced** + - PR description must reference a GitHub issue and include one of: + - `Close #` / `Closes #` / `Fixes #` + +## Local checks (run before pushing) +- `npm run type-check` +- `npm run lint` +- `npm run test` +- `npm run build` + +## PR description format +Use the PR template (auto-applied). Ensure it includes: +- Summary of changes +- Testing notes +- `Close #` + +## Code standards +- Keep changes small and focused. +- No console errors. +- Use `lucide-react` icons for UI. +- Keep components accessible and responsive. + +## Security +Do not commit secrets. Use `.env.local` for local environment variables. diff --git a/README.md b/README.md index 1824d45..3dcd29d 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,16 @@ npm run dev For detailed tasks, see GitHub Issues -🤝 Contributing +## 🤝 Contributing We welcome community contributions! -Guidelines: -Fork the repo and make your changes in a feature branch +- Read **`CONTRIBUTING.md`** before opening a PR. +- All PRs must include an issue reference in the description (e.g. `Closes #68`). +- Merges to protected branches require passing CI + approvals. -Before submitting a PR, read the CONTRIBUTING.md file +Guidelines: +- Fork the repo and make your changes in a feature branch +- Before submitting a PR, read the **`CONTRIBUTING.md`** file ## 📬 Join the Community @@ -144,5 +147,3 @@ let make our code clean, maintainable and scallable. Keep to Standard 📜 License MIT © 2025 TeachLink DAO - -``` diff --git a/src/form-management/state/form-state-manager.test.ts b/src/form-management/state/form-state-manager.test.ts index 9b5ceae..15b4931 100644 --- a/src/form-management/state/form-state-manager.test.ts +++ b/src/form-management/state/form-state-manager.test.ts @@ -2,7 +2,7 @@ * Unit tests for Form State Manager */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { FormStateManager } from './form-state-manager'; import { ValidationResult, StateChangeEvent } from '../types/core'; @@ -214,7 +214,7 @@ describe('FormStateManager', () => { describe('state change subscriptions', () => { it('should notify subscribers of field changes', () => { - const callback = jest.fn(); + const callback = vi.fn(); const subscription = stateManager.subscribeToChanges(callback); stateManager.updateField('email', 'test@example.com'); @@ -232,7 +232,7 @@ describe('FormStateManager', () => { }); it('should notify subscribers of validation changes', () => { - const callback = jest.fn(); + const callback = vi.fn(); stateManager.subscribeToChanges(callback); const validationResult: ValidationResult = { @@ -252,10 +252,10 @@ describe('FormStateManager', () => { }); it('should handle subscription errors gracefully', () => { - const errorCallback = jest.fn(() => { - throw new Error('Callback error'); + const errorCallback = vi.fn(() => { + throw new Error('subscription error'); }); - const normalCallback = jest.fn(); + const normalCallback = vi.fn(); stateManager.subscribeToChanges(errorCallback); stateManager.subscribeToChanges(normalCallback); @@ -269,7 +269,7 @@ describe('FormStateManager', () => { }); it('should unsubscribe correctly', () => { - const callback = jest.fn(); + const callback = vi.fn(); const subscription = stateManager.subscribeToChanges(callback); stateManager.updateField('email', 'test1@example.com'); @@ -333,7 +333,7 @@ describe('FormStateManager', () => { describe('programmatic state control methods', () => { describe('silent field updates', () => { it('should set field value without triggering change events', () => { - const callback = jest.fn(); + const callback = vi.fn(); stateManager.subscribeToChanges(callback); stateManager.setFieldValueSilently('email', 'test@example.com'); @@ -345,7 +345,7 @@ describe('FormStateManager', () => { describe('batch operations', () => { it('should set multiple validation states at once', () => { - const callback = jest.fn(); + const callback = vi.fn(); stateManager.subscribeToChanges(callback); const validationStates = { @@ -364,7 +364,7 @@ describe('FormStateManager', () => { }); it('should set multiple field values in batch', () => { - const callback = jest.fn(); + const callback = vi.fn(); stateManager.subscribeToChanges(callback); const values = { @@ -471,7 +471,7 @@ describe('FormStateManager', () => { describe('submission control', () => { it('should start submission with callback', () => { - const callback = jest.fn(); + const callback = vi.fn(); stateManager.startSubmission(callback); expect(stateManager.getState().isSubmitting).toBe(true); @@ -479,7 +479,7 @@ describe('FormStateManager', () => { }); it('should complete submission successfully', () => { - const callback = jest.fn(); + const callback = vi.fn(); // Set up dirty state stateManager.updateField('email', 'test@example.com'); @@ -493,7 +493,7 @@ describe('FormStateManager', () => { }); it('should complete submission with failure', () => { - const callback = jest.fn(); + const callback = vi.fn(); // Set up dirty state stateManager.updateField('email', 'test@example.com'); @@ -686,7 +686,7 @@ describe('FormStateManager', () => { }); it('should trigger cascading updates manually', () => { - const callback = jest.fn(); + const callback = vi.fn(); stateManager.subscribeToChanges(callback); stateManager.updateField('trigger', 'show'); diff --git a/src/form-management/utils/configuration-parser.test.ts b/src/form-management/utils/configuration-parser.test.ts index 2d33ecb..d2e09fc 100644 --- a/src/form-management/utils/configuration-parser.test.ts +++ b/src/form-management/utils/configuration-parser.test.ts @@ -511,22 +511,40 @@ describe('FormConfigurationParser', () => { it('Property: Configuration round-trip should produce equivalent object', () => { // Create a generator for valid FormConfiguration objects const validConfigArbitrary = fc.record({ - id: fc.string({ minLength: 1 }), - version: fc.string({ minLength: 1 }), - title: fc.string({ minLength: 1 }), - description: fc.option(fc.string()), + id: fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + version: fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + title: fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + // schema: description is optional string (undefined allowed, null not allowed) + description: fc.option(fc.string({ minLength: 1 }), { nil: undefined }), fields: fc.array( fc.record({ - id: fc.string({ minLength: 1 }), - type: fc.constantFrom('text', 'number', 'email', 'password', 'select', 'checkbox', 'radio', 'textarea', 'file', 'date', 'time', 'datetime-local') as fc.Arbitrary, - label: fc.string({ minLength: 1 }), - placeholder: fc.option(fc.string()), + id: fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + type: fc.constantFrom( + 'text', + 'number', + 'email', + 'password', + 'select', + 'checkbox', + 'radio', + 'textarea', + 'file', + 'date', + 'time', + 'datetime-local' + ) as fc.Arbitrary, + label: fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + // schema: placeholder is optional string (undefined allowed, null not allowed) + placeholder: fc.option(fc.string({ minLength: 1 }), { nil: undefined }), required: fc.boolean(), - validation: fc.array(fc.record({ - type: fc.constantFrom('required', 'email', 'minLength', 'maxLength', 'pattern', 'custom', 'async'), - params: fc.option(fc.dictionary(fc.string(), fc.anything())), - message: fc.string() - })) + validation: fc.array( + fc.record({ + type: fc.constantFrom('required', 'email', 'minLength', 'maxLength', 'pattern', 'custom', 'async'), + // schema: params is optional record (undefined allowed, null not allowed) + params: fc.option(fc.dictionary(fc.string({ minLength: 1 }), fc.anything()), { nil: undefined }), + message: fc.string({ minLength: 1 }) + }) + ) }), { minLength: 1 } ), @@ -534,15 +552,20 @@ describe('FormConfigurationParser', () => { type: fc.constantFrom('single-column', 'two-column', 'grid', 'custom'), spacing: fc.constantFrom('compact', 'normal', 'relaxed'), responsive: fc.record({ - breakpoints: fc.dictionary(fc.string(), fc.nat()), - layouts: fc.dictionary(fc.string(), fc.constant({})) + breakpoints: fc.dictionary(fc.string({ minLength: 1 }), fc.integer({ min: 0, max: 4096 })), + // schema expects LayoutConfiguration objects; easiest is to provide an empty dict + // (zod allows empty record and will validate values only if present) + layouts: fc.constant({}) }) }), validation: fc.record({ validateOnChange: fc.boolean(), validateOnBlur: fc.boolean(), showErrorsOnSubmit: fc.boolean(), - debounceMs: fc.nat(), + debounceMs: fc.integer({ min: 0, max: 60000 }), + // schema expects record, but validate() only schema-parses when called with config; + // in round trip we also call validate(parsedConfig) which requires functions. + // Keep it empty. customRules: fc.constant({}) }) }); @@ -622,7 +645,7 @@ describe('FormConfigurationParser', () => { } }; - const result = parser.validate(configWithInvalidField as FormConfiguration); + const result = parser.validate(configWithInvalidField as unknown as FormConfiguration); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); diff --git a/src/form-management/utils/configuration-parser.ts b/src/form-management/utils/configuration-parser.ts index efaea94..431395e 100644 --- a/src/form-management/utils/configuration-parser.ts +++ b/src/form-management/utils/configuration-parser.ts @@ -176,6 +176,12 @@ export class FormConfigurationParser implements ConfigurationParser { if (error instanceof SyntaxError) { throw new Error(`Invalid JSON: ${error.message}`); } + + // Normalize schema validation errors to a stable message for callers/tests + if (error instanceof z.ZodError) { + throw new Error(`Configuration validation failed: ${error.errors.map(e => e.message).join(', ')}`); + } + throw error; } }