diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..6a08066 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,60 @@ +--- +name: release +description: Draft a new GitHub release for buildwithhussain/forms_pro by inspecting existing releases and merged PRs since the last release. Use when the user wants to cut, draft, or create a release. +argument-hint: [version] +--- + +You are preparing a new GitHub release for the repo `buildwithhussain/forms_pro`. + +## Steps + +1. **Fetch current releases** + Run: `gh release list --repo buildwithhussain/forms_pro` + +2. **Inspect the latest release** to get its tag and published date: + Run: `gh release view --repo buildwithhussain/forms_pro` + +3. **List all merged PRs** since the last release (sorted by merge date): + Run: `gh pr list --repo buildwithhussain/forms_pro --state merged --limit 100 --json number,title,author,mergedAt | jq 'sort_by(.mergedAt)'` + Filter to only PRs merged **after** the last release's published date. + +4. **Determine the next version** + - If `$ARGUMENTS` is provided, use it as the tag (e.g. `v0.1.3-beta`). + - Otherwise, infer the next patch version from the latest tag (e.g. `v0.1.1-beta` → `v0.1.2-beta`). + +5. **Categorize PRs** into: + - **Features** — titles starting with `feat:` + - **Fixes** — titles starting with `fix:` + - **Chores / CI** — titles starting with `chore:`, `refactor:`, `ci:`, `docs:` — **omit these from release notes** + +6. **Show the user a plan**: list the proposed tag, target branch (`main`), and the drafted release notes. Ask for confirmation before creating anything. + +7. **After confirmation**, create a draft release: + ``` + gh release create \ + --repo buildwithhussain/forms_pro \ + --title "" \ + --draft \ + --target main \ + --notes "" + ``` + +8. Return the draft release URL so the user can review and publish it. + +## Release Notes Format + +Follow the same format as previous releases exactly: + +```markdown +## What's Changed + +### Features +* feat: by @<author> in https://github.com/BuildWithHussain/forms_pro/pull/<number> + +### Fixes +* fix: <title> by @<author> in https://github.com/BuildWithHussain/forms_pro/pull/<number> + +**Full Changelog**: https://github.com/BuildWithHussain/forms_pro/compare/<previous-tag>...<new-tag> +``` + +Omit a section entirely if there are no PRs in that category. diff --git a/.claude/skills/writing-tests/SKILL.md b/.claude/skills/writing-tests/SKILL.md new file mode 100644 index 0000000..0ae27d5 --- /dev/null +++ b/.claude/skills/writing-tests/SKILL.md @@ -0,0 +1,149 @@ +--- +name: writing-tests +description: Use when writing or modifying backend tests in `forms_pro/tests/`. Forms Pro test suites must build test documents via `frappe_factory_bot` factories rather than `frappe.new_doc` / `frappe.get_doc`. This skill explains where factories live, how to author one, and how to consume them in tests. +--- + +# Writing Tests (frappe_factory_bot) + +The dev bench has `frappe_factory_bot` installed alongside Forms Pro (`apps/frappe_factory_bot`, repo: `harshtandiya/frappe_factory_bot`). All Forms Pro tests build their fixtures through factories defined in `forms_pro/tests/factories/`. + +## Rules + +1. **Never** call `frappe.new_doc(...)` or `frappe.get_doc({...}).insert()` directly in a test to spin up a fixture. Use a factory. +2. One factory per doctype. File name: `<snake_case_doctype>_factory.py` inside `forms_pro/tests/factories/`. **Naming exception**: when a doctype is used in a narrow domain context (e.g. the generic `"DocType"` doctype acting as a placeholder for a Form's linked DocType), name the factory after the domain — `LinkedFormDoctypeFactory`, not `DoctypeFactory`. +3. Factory class name: `<PascalCaseDocType>Factory`, subclassing `BaseFactory[<DocClass>]` (parameterise the generic with the actual `Document` subclass for type hints). +4. `default_attributes` must only set the minimum fields required for `.insert()` to succeed. Use `Faker` (`_fake = Faker()`) with `_fake.unique.*` when a field must be unique. +5. Reuse the factory bot's primitives instead of reinventing them: + - **Traits**: extra `@property` methods returning attribute dicts. Apply with `Factory.create("trait_name")`. + - **Overrides**: kwargs to `create()` / `build()`. Take precedence over traits and defaults. + - **Relationships (in traits)**: in a trait that fabricates a related doc, *always* honour `self.overrides.get("<fk_field>")` before creating a new dependent record (prevents orphans). + - **Relationships (in defaults)**: when a required FK has no sensible literal default, use the same `self.overrides.get(...) or RelatedFactory.create().name` short-circuit inside `default_attributes` so callers can pass an existing record without spawning an orphan. +6. Factory bot owns teardown via `__del__` hooks. Do **not** add manual cleanup of factory-built docs in tearDown. +7. **Do not override `create()`** to wrap heavy side effects. If a doctype needs more than `frappe.get_doc({...}).insert()` to be usable (parent DocType, DocShare, etc.), build a separate factory for the dependency and reference it from `default_attributes` (see rule 5). Overriding `create()` breaks `build_list` / `create_list` and diverges from the convention. +8. If a doctype you need has no factory yet, write one first, then write the test. Keep the factory commit / change separate-ish from test logic when reasonable. + +## Authoring a factory + +Template — replace placeholders. Note the generic `BaseFactory[FooBar]` typing. + +```python +from typing import Any + +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from forms_pro.forms_pro.doctype.foo_bar.foo_bar import FooBar + +_fake = Faker() + + +class FooBarFactory(BaseFactory[FooBar]): + doctype = "Foo Bar" + + @property + def default_attributes(self) -> dict[str, Any]: + from forms_pro.tests.factories.fp_team_factory import FPTeamFactory + + return { + "title": _fake.unique.sentence(nb_words=3), + "is_active": 1, + # Lazy-create the FK only if the caller did not pass one. + "owner_team": self.overrides.get("owner_team") or FPTeamFactory.create().name, + } + + @property + def with_owner_team(self) -> dict[str, Any]: + # Same honour-override pattern, applicable to optional FKs surfaced via traits. + from forms_pro.tests.factories.fp_team_factory import FPTeamFactory + + return { + "owner_team": self.overrides.get("owner_team") or FPTeamFactory.create().name, + } +``` + +Reference existing factories: +- `forms_pro/tests/factories/user_factory.py` — `Faker.unique`, `with_forms_pro_role` trait that seeds a child table. +- `forms_pro/tests/factories/fp_team_factory.py` — minimal default attributes. +- `forms_pro/tests/factories/user_invitation_factory.py` — overrides-aware defaults + `to_forms_pro_app` trait. +- `forms_pro/tests/factories/form_factory.py` + `linked_form_doctype_factory.py` — pattern for a doctype with non-trivial dependencies; defaults lazy-create the placeholder DocType and team. + +## Gotchas + +### `__del_override__` fires on garbage collection + +`BaseFactory._attach_del` swaps the returned doc's class so its `__del__` runs cleanup. Python invokes `__del__` when the doc is GC'd. If you chain `Factory.create().name` and discard the doc, the cleanup may run **before** the FK is used downstream, deleting the doc you just relied on. + +```python +# Risky — DocType may be GC'd before the Form insert reads its name. +fk = MyDocTypeFactory.create().name + +# Safe — binding keeps the doc alive for the test's lifetime. +parent = MyDocTypeFactory.create() +fk = parent.name +``` + +For factories that do not need destructive cleanup (e.g. DocTypes whose unique random names provide isolation), it is acceptable to skip the `__del_override__` override entirely. + +### `before_insert` / `validate` can clobber overrides + +Some doctypes hard-set fields during `before_insert` (e.g. `Form.before_insert` forces `is_published = False`). Passing `is_published=1` as a factory override is silently overwritten — the override is merged into the dict that becomes the new doc, but `before_insert` runs after that and overwrites the attribute. + +Patch post-insert when this matters: + +```python +form = FormFactory.create(...) +form.is_published = 1 +form.save(ignore_permissions=True) +``` + +Rule of thumb: if the doctype hard-sets a field in `before_insert`, `validate`, or `before_validate`, factory overrides cannot reach it — touch the field after `.create()` and save again. + +## Consuming factories in tests + +```python +import frappe +from frappe.tests import IntegrationTestCase + +from forms_pro.tests.factories.user_factory import UserFactory +from forms_pro.tests.factories.fp_team_factory import FPTeamFactory + + +class TestSomething(IntegrationTestCase): + def test_invites_existing_member(self) -> None: + user = UserFactory.create("with_forms_pro_role") + team = FPTeamFactory.create(owner=user.name) + # IDE knows `user: User` and `team: FPTeam` thanks to BaseFactory[T]. + frappe.set_user(user.name) + ... +``` + +Method cheat sheet (all class methods on the factory): + +| Call | Returns | Saved? | +| --- | --- | --- | +| `Factory.build(*traits, **overrides)` | `T` | no | +| `Factory.create(*traits, **overrides)` | `T` | yes | +| `Factory.build_list(n, *traits, **overrides)` | `list[T]` | no | +| `Factory.create_list(n, *traits, **overrides)` | `list[T]` | yes | + +Precedence: overrides > traits > defaults. Passing a trait that does not exist raises `TypeError`. + +## Test runner + +```bash +bench --site forms.dev run-tests --module forms_pro.tests.test_<module> +``` + +The command above will run tests inside the module. + +If you want to run all tests, you can use the following command: + +```bash +bench --site forms.dev run-tests --app forms_pro +``` + +If you want to run a specific test, you can use the following command: + +```bash +bench --site forms.dev run-tests --module *path_to_test_file* --test *test_method_name* +``` diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000..f79b4a4 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,17 @@ +name: Backport + +on: + pull_request_target: + types: [closed, labeled] + +jobs: + backport: + if: github.event.pull_request.merged && contains(join(github.event.pull_request.labels.*.name, ','), 'backport/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: korthout/backport-action@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + pull_title: "[backport] ${pull_title}" + label_pattern: "^backport/([^ ]+)$" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b6fcf6..8159913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -# Deployment branch for Frappe version-15 (Python 3.10, Node 18, MariaDB 10.6.24) +# Deployment branch for Frappe version-15 (Python 3.10, Node 20, MariaDB 10.6.24) on: push: branches: @@ -12,7 +12,6 @@ on: - version-15 paths-ignore: - ".github/**" - merge_group: concurrency: group: version-15-forms_pro-${{ github.event.number }} diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 4ab2979..4d21b08 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -7,7 +7,6 @@ on: - version-15 paths-ignore: - ".github/**" - merge_group: workflow_dispatch: permissions: @@ -21,7 +20,7 @@ jobs: linter: name: 'Frappe Linter' runs-on: ubuntu-latest - if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index bdc1427..49c2eb6 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -1,6 +1,6 @@ name: Frontend TypeScript -# Frappe version-15 deployment branch: Node 18 +# Frappe version-15 deployment branch: Node 20 on: push: branches: @@ -14,7 +14,6 @@ on: paths: - "frontend/**" - ".github/workflows/typecheck.yml" - merge_group: concurrency: group: typecheck-version-15-forms_pro-${{ github.event.number || github.sha }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index b56d26d..6afc64c 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -1,11 +1,13 @@ name: UI Tests +# Frappe version-15 deployment branch (Python 3.10, Node 20) on: push: branches: - - develop + - version-15 pull_request: - merge_group: + branches: + - version-15 workflow_dispatch: concurrency: @@ -28,7 +30,7 @@ jobs: ports: - 11000:6379 mariadb: - image: mariadb:10.6 + image: mariadb:10.6.24 env: MYSQL_ROOT_PASSWORD: root ports: @@ -42,12 +44,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: "3.14" + python-version: "3.10" - name: Setup Node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 20 check-latest: true - name: Add to Hosts @@ -90,7 +92,7 @@ jobs: - name: Setup Frappe bench run: | pip install frappe-bench - bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + bench init ~/frappe-bench --version version-15 --skip-redis-config-generation --skip-assets --python "$(which python)" mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c2bb07b..70888a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,22 +41,11 @@ repos: .*boilerplate.* )$ - - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.44.0 + - repo: https://github.com/oxc-project/mirrors-oxlint + rev: v1.60.0 hooks: - - id: eslint - types_or: [javascript, ts] - args: ["--quiet"] - # Ignore any files that might contain jinja / bundles - exclude: | - (?x)^( - frontend/components.d.ts| - frontend/auto-imports.d.ts| - forms_pro/public/frontend/.*| - cypress/.*| - .*node_modules.*| - .*boilerplate.* - )$ + - id: oxlint + types_or: [javascript, ts, vue] ci: autoupdate_schedule: weekly diff --git a/CLAUDE.md b/CLAUDE.md index 111497f..a2a1593 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ npm run build # TypeScript type check cd frontend && yarn typecheck -# Lint & format (BiomeJS) +# Lint (oxlint) & format (Prettier via pre-commit) cd frontend && yarn lint ``` @@ -107,6 +107,7 @@ When a form is saved, its fields are synced to the linked DocType as `CustomFiel | `/release [version]` | Draft a new GitHub release. Inspects merged PRs since the last release, categorizes them, and creates a draft on GitHub for review. | | `/add-field <FieldtypeName>` | Add a new field type end-to-end: backend doctype + mapping, submission serialization, frontend component, fieldTypes registry, options resolution, and submission display. | | `/userinterface-wiki` | Review UI/UX against best practices — animations, CSS, typography, UX patterns, prefetching, icons. Outputs file:line findings. | +| `/writing-tests` | Use `frappe_factory_bot` factories under `forms_pro/tests/factories/` for backend test fixtures (never `frappe.new_doc` / `frappe.get_doc`). Covers authoring a typed factory, traits, overrides, and consumption patterns. Auto-applies when writing/modifying tests. | > **Adding skills via `npx skills`:** Always use `--copy` and target `.claude/skills/` explicitly so Claude Code can discover them: > ```bash @@ -116,8 +117,9 @@ When a form is saved, its fields are synced to the linked DocType as `CustomFiel ## Key Conventions -- **Python target**: 3.10+; formatted with `ruff` +- **Python target**: 3.14+; formatted with `ruff` - **TypeScript**: strict mode; `vue-tsc` enforced in CI - **Frappe patterns**: use `frappe.get_doc`, `frappe.get_all`, `frappe.db.*`; avoid raw SQL unless necessary - **PR titles**: validated by CI workflow (conventional commit format expected) - **Tests**: use Frappe's `IntegrationTestCase`; test infrastructure set up in `install.py:before_tests()` +- **Test fixtures**: build documents via factories in `forms_pro/tests/factories/` (powered by `frappe_factory_bot`, repo `harshtandiya/frappe_factory_bot`). Do **not** use `frappe.new_doc` / `frappe.get_doc(...).insert()` in tests. See the `/writing-tests` skill. diff --git a/forms_pro/api/export/__init__.py b/forms_pro/api/export/__init__.py new file mode 100644 index 0000000..c6b2652 --- /dev/null +++ b/forms_pro/api/export/__init__.py @@ -0,0 +1,3 @@ +from .endpoints import export_submissions + +__all__ = ["export_submissions"] diff --git a/forms_pro/api/export/endpoints.py b/forms_pro/api/export/endpoints.py new file mode 100644 index 0000000..a0e9091 --- /dev/null +++ b/forms_pro/api/export/endpoints.py @@ -0,0 +1,85 @@ +from typing import Literal + +import frappe +from frappe import _ +from frappe.core.doctype.access_log.access_log import make_access_log +from frappe.core.doctype.data_export.exporter import DataExporter +from werkzeug.utils import secure_filename + +from forms_pro.forms_pro.doctype.form.form import Form +from forms_pro.utils.permissions import require_permission + +_SUPPORTED_FILE_TYPES = ("CSV", "Excel") + + +@frappe.whitelist() +@require_permission("Form", "write", param="form_id") +def export_submissions(form_id: str, file_type: Literal["CSV", "Excel"] = "CSV") -> None: + """ + Export submissions for a form as a CSV or XLSX download. + + Permission model: gated on `write` on the Form doctype only. + Frappe's `DataExporter` enforces `can_export` on the linked DocType, + which our app deliberately does not grant at the role level (Forms Pro + permissions live on the Form, not on the linked DocType). To bridge + that gap we run the exporter as Administrator and restore the original + session user in `finally`. The Form-level permission check above is + the real authorization gate. + """ + # `Literal` is not enforced at runtime; validate explicitly. + if file_type not in _SUPPORTED_FILE_TYPES: + frappe.throw( + _("Unsupported file_type {0}. Allowed: {1}.").format(file_type, ", ".join(_SUPPORTED_FILE_TYPES)), + frappe.ValidationError, + ) + + form: Form = frappe.get_doc("Form", form_id) + linked_doctype = form.linked_doctype + + columns = [ + "name", + "creation", + "owner", + "fp_submission_status", + *[field.fieldname for field in form.fields if field.stores_value], + ] + select_columns = {linked_doctype: columns} + + # Audit the export under the real (non-Admin) user before the swap. + make_access_log( + doctype=linked_doctype, + file_type=file_type, + columns=frappe.as_json(columns), + method="forms_pro.api.export.export_submissions", + ) + + # `frappe.set_user` is built for background jobs, not web requests: it overwrites `local.session.sid` with the username AND wipes `local.session.data` (which holds `last_updated`, `lang`, csrf token). After the response, `Session.update()` persists that empty data into the DB row + cache; the next request then sees `last_updated=None`, the expiry check treats the session as expired, the row is deleted, and the user is logged out. Snapshot user / sid / data and restore all three after the privilege swap. + saved_user = frappe.session.user + saved_sid = frappe.session.sid + saved_data = frappe.session.data + try: + # Authorization is already enforced via `has_permission("Form", "write", ...)` above. The swap bypasses `can_export` on the linked DocType (intentionally not granted at the role level in Forms Pro). Session state is snapshotted immediately above and fully restored in `finally`. Audited 2026-05-13. + frappe.set_user("Administrator") # nosemgrep: frappe-semgrep-rules.rules.security.frappe-setuser + exporter = DataExporter( + doctype=linked_doctype, + all_doctypes=False, + with_data=True, + select_columns=frappe.as_json(select_columns), + filters=frappe.as_json({"fp_linked_form": form_id}), + file_type=file_type, + export_without_column_meta=True, + ) + exporter.build_response() + safe_title = secure_filename(form.title or "") or form_id + timestamp = frappe.utils.now_datetime().strftime("%Y-%m-%d_%H%M%S") + base_name = f"Submissions_{safe_title}_{timestamp}" + frappe.response["doctype"] = base_name + if file_type == "Excel": + frappe.response["filename"] = f"{base_name}.xlsx" + except Exception as e: + frappe.log_error(title="export_submissions: failed to export submissions", message=str(e)) + raise e + finally: + frappe.set_user(saved_user) # nosemgrep: frappe-semgrep-rules.rules.security.frappe-setuser + frappe.local.session.sid = saved_sid + frappe.local.session.data = saved_data diff --git a/forms_pro/api/export/test_export.py b/forms_pro/api/export/test_export.py new file mode 100644 index 0000000..0e17123 --- /dev/null +++ b/forms_pro/api/export/test_export.py @@ -0,0 +1,323 @@ +# Copyright (c) 2025, harsh@buildwithhussain.com and contributors +# For license information, please see license.txt + +""" +Tests for `forms_pro.api.export.export_submissions`. + +Cover three bugs reported in review: +- file_type="Excel" silently produced CSV because Frappe's DataExporter + expects "Excel"; an XLSX literal was being passed through unchanged. +- `file_type` Literal was not validated at runtime. +- Export ignored `fp_linked_form`, returning rows of every form sharing + the linked DocType. + +Plus invariants of the privilege-swap pattern: access log under the +real user, session user restored on success and on failure. +""" + +from unittest.mock import patch + +import frappe +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase + +from forms_pro.api.export import export_submissions +from forms_pro.api.submission import submit_form_response +from forms_pro.tests import FORMS_PRO_TEST_USER +from forms_pro.tests.factories.form_factory import FormFactory + +_SAMPLE_FIELDS = [ + {"fieldname": "full_name", "fieldtype": "Data", "label": "Full Name", "reqd": 1}, + {"fieldname": "email_address", "fieldtype": "Data", "label": "Email", "options": "Email"}, +] + + +def _create_form(**overrides): + """Build a published form carrying two real fields, ready for submissions. + + `Form.before_insert` hard-codes `is_published = False`, so publishing + must happen post-insert. + """ + form = FormFactory.create( + fields=[dict(row) for row in _SAMPLE_FIELDS], + **overrides, + ) + form.is_published = 1 + form.save(ignore_permissions=True) + return form + + +def _submit(form, **values) -> str: + """Seed a submission via the real submission API so fp_linked_form is set + by production code rather than by the test.""" + return submit_form_response( + form_id=form.name, + form_data=[{"fieldname": k, "value": v} for k, v in values.items()], + ) + + +class TestExportFileType(IntegrationTestCase): + """Covers `file_type` argument handling: dispatch, validation, defaults.""" + + def setUp(self) -> None: + frappe.set_user("Administrator") + self.form = _create_form() + + def test_excel_file_type_returns_xlsx_response(self) -> None: + """`file_type="Excel"` must hit DataExporter's XLSX branch, not CSV. + + Previously the public API accepted "XLSX" and forwarded it to + DataExporter, which only matches `"Excel"` and silently fell through + to the CSV writer. Asserting `response.type != "csv"` guards the + dispatch path regardless of how Frappe encodes the xlsx response. + """ + export_submissions(form_id=self.form.name, file_type="Excel") + self.assertNotEqual( + frappe.response.get("type"), + "csv", + "Excel file_type should not produce a CSV response.", + ) + + def test_invalid_file_type_raises_validation_error(self) -> None: + """An unsupported file_type must raise at the boundary. + + `Literal[...]` is a static hint only; the whitelisted method + receives whatever string the caller sends. An explicit runtime + check converts garbage input into a `ValidationError` instead of + a confusing internal failure deeper in DataExporter. + """ + with self.assertRaises(frappe.ValidationError): + export_submissions(form_id=self.form.name, file_type="PDF") + + +class TestExportScopedByForm(IntegrationTestCase): + """Two forms share a linked DocType. Export must not bleed across forms.""" + + def setUp(self) -> None: + frappe.set_user("Administrator") + # Two forms over the same linked DocType so we can prove the export + # is scoped by fp_linked_form, not by linked_doctype. + self.form_a = _create_form() + self.form_b = _create_form(linked_doctype=self.form_a.linked_doctype) + + self.row_a = _submit(self.form_a, full_name="Alice A", email_address="alice@example.com") + self.row_b = _submit(self.form_b, full_name="Bob B", email_address="bob@example.com") + + def test_export_returns_only_current_forms_submissions(self) -> None: + """Exporting form_a must include form_a's rows and exclude form_b's. + + Without a `fp_linked_form` filter the DataExporter pulled every + row of the linked DocType, leaking other forms' submissions. + Asserts at both the submission-id level and the field-value level. + """ + export_submissions(form_id=self.form_a.name, file_type="CSV") + csv_body = frappe.response.get("result") or "" + + self.assertIn(self.row_a, csv_body, "form_a submission must appear in export.") + self.assertIn("Alice A", csv_body, "form_a field value must appear in export.") + self.assertNotIn( + self.row_b, + csv_body, + "form_b submission must NOT appear in form_a's export.", + ) + self.assertNotIn( + "Bob B", + csv_body, + "form_b field value must NOT appear in form_a's export.", + ) + + def test_export_csv_header_includes_form_field_labels(self) -> None: + """CSV header row carries human-readable labels of all exportable fields.""" + export_submissions(form_id=self.form_a.name, file_type="CSV") + csv_body = frappe.response.get("result") or "" + # `export_without_column_meta=True` collapses metadata rows; the + # first row carries the labels. + header_line = csv_body.splitlines()[0] if csv_body else "" + self.assertIn("Full Name", header_line) + self.assertIn("Email", header_line) + + +class TestExportFieldSelection(IntegrationTestCase): + """Which form fields end up in the CSV column set.""" + + def setUp(self) -> None: + frappe.set_user("Administrator") + + def test_display_only_fields_excluded_from_export(self) -> None: + """Heading / display-only fields have no DB column and must not + appear as CSV columns. + + The `FormField.stores_value` filter in `export.py` drops any field + whose resolved Frappe fieldtype is in `no_value_fields`. + """ + form = FormFactory.create( + fields=[ + {"fieldname": "intro_heading", "fieldtype": "Heading 1", "label": "Welcome"}, + {"fieldname": "full_name", "fieldtype": "Data", "label": "Full Name"}, + ], + ) + form.is_published = 1 + form.save(ignore_permissions=True) + + export_submissions(form_id=form.name, file_type="CSV") + csv_body = frappe.response.get("result") or "" + header_line = csv_body.splitlines()[0] if csv_body else "" + + self.assertNotIn( + "intro_heading", + csv_body, + "Heading field name must not appear anywhere in the export.", + ) + self.assertNotIn( + "Welcome", + csv_body, + "Heading label must not be promoted to a CSV column.", + ) + self.assertIn("Full Name", header_line) + + def test_all_exportable_fields_present_in_csv_header(self) -> None: + """Every non-display field defined on the form contributes one column.""" + fields = [ + {"fieldname": "full_name", "fieldtype": "Data", "label": "Full Name"}, + {"fieldname": "age", "fieldtype": "Number", "label": "Age"}, + {"fieldname": "email_address", "fieldtype": "Email", "label": "Email"}, + {"fieldname": "joined_on", "fieldtype": "Date", "label": "Joined On"}, + ] + form = FormFactory.create(fields=fields) + form.is_published = 1 + form.save(ignore_permissions=True) + + export_submissions(form_id=form.name, file_type="CSV") + csv_body = frappe.response.get("result") or "" + header_line = csv_body.splitlines()[0] if csv_body else "" + + for field in fields: + self.assertIn( + field["label"], + header_line, + f"Field label {field['label']!r} missing from CSV header.", + ) + + +class TestExportPermissions(IntegrationTestCase): + """Authorization gate on `export_submissions`.""" + + def test_user_without_write_permission_raises_permission_error(self) -> None: + """A Forms Pro user with no DocShare on the form must be rejected. + + Export is gated on Form-level `write`. The test user holds the + Forms Pro role but has no team membership or share for a form + owned by Administrator, so `has_permission` returns False. + """ + frappe.set_user("Administrator") + form = _create_form() + + frappe.set_user(FORMS_PRO_TEST_USER) + try: + with self.assertRaises(frappe.PermissionError) as ctx: + export_submissions(form_id=form.name, file_type="CSV") + self.assertEqual(ctx.exception.http_status_code, 403) + finally: + frappe.set_user("Administrator") + + def test_raises_404_when_form_missing(self) -> None: + """A missing form_id must surface as DoesNotExistError (404) so the + frontend renders the Not Found state, not Access Denied.""" + frappe.set_user("Administrator") + with self.assertRaises(frappe.DoesNotExistError) as ctx: + export_submissions(form_id="MISSING_FORM_XYZ", file_type="CSV") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestExportAccessLog(IntegrationTestCase): + """An export must be recorded in the Access Log for audit.""" + + def test_access_log_row_written_for_export(self) -> None: + """`make_access_log` runs before the privilege swap so the log + entry is attributed to the real user, not Administrator. + """ + frappe.set_user("Administrator") + form = _create_form() + + before = frappe.db.count( + "Access Log", + filters={"method": "forms_pro.api.export.export_submissions"}, + ) + export_submissions(form_id=form.name, file_type="CSV") + # `make_access_log` uses `deferred_insert` outside tests; in tests + # it uses `db_insert`, so the row is visible immediately. + after = frappe.db.count( + "Access Log", + filters={"method": "forms_pro.api.export.export_submissions"}, + ) + self.assertEqual(after, before + 1, "Export must add one Access Log row.") + + latest = frappe.get_last_doc( + "Access Log", + filters={"method": "forms_pro.api.export.export_submissions"}, + ) + self.assertEqual( + latest.user, + "Administrator", + "Access Log must be attributed to the caller, not the swapped user.", + ) + self.assertEqual(latest.export_from, form.linked_doctype) + + +class TestExportSessionRestoration(IntegrationTestCase): + """The privilege-swap finally block must restore the real session.""" + + def setUp(self) -> None: + # Build the form as Administrator, share it with the test user so + # the perm check passes, then run the export AS the test user. + # Comparing against a non-Admin user is essential: if `before` were + # Administrator the assertion would be tautological since the swap + # also lands on Administrator. + from frappe.share import add_docshare + + frappe.set_user("Administrator") + self.form = _create_form() + add_docshare( + "Form", + self.form.name, + user=FORMS_PRO_TEST_USER, + read=1, + write=1, + flags={"ignore_share_permission": True}, + ) + + def tearDown(self) -> None: + frappe.set_user("Administrator") + + def test_session_user_restored_after_successful_export(self) -> None: + """`frappe.session.user` must equal the pre-call user (non-Admin) + after a successful export. Detects a missing `set_user` restore.""" + frappe.set_user(FORMS_PRO_TEST_USER) + before = frappe.session.user + self.assertEqual(before, FORMS_PRO_TEST_USER) # sanity + + export_submissions(form_id=self.form.name, file_type="CSV") + + self.assertEqual( + frappe.session.user, + FORMS_PRO_TEST_USER, + "Session user must be restored after a successful export.", + ) + + def test_session_user_restored_when_exporter_raises(self) -> None: + """If DataExporter blows up mid-export, the finally block must + still restore the session user; otherwise the request would + continue running as Administrator (privilege leak).""" + frappe.set_user(FORMS_PRO_TEST_USER) + + with patch( + "forms_pro.api.export.endpoints.DataExporter.build_response", + side_effect=RuntimeError("boom"), + ): + with self.assertRaises(RuntimeError): + export_submissions(form_id=self.form.name, file_type="CSV") + + self.assertEqual( + frappe.session.user, + FORMS_PRO_TEST_USER, + "Session user must be restored even when the exporter raises.", + ) diff --git a/forms_pro/api/form/__init__.py b/forms_pro/api/form/__init__.py new file mode 100644 index 0000000..5828597 --- /dev/null +++ b/forms_pro/api/form/__init__.py @@ -0,0 +1,29 @@ +from .endpoints import ( + add_form_access, + get_doctype_fields, + get_doctype_list, + get_form, + get_form_by_route, + get_form_for_edit, + get_form_for_view, + get_form_shared_with, + get_link_field_options, + is_login_required, + remove_form_access, + set_form_permission, +) + +__all__ = [ + "add_form_access", + "get_doctype_fields", + "get_doctype_list", + "get_form", + "get_form_by_route", + "get_form_for_edit", + "get_form_for_view", + "get_form_shared_with", + "get_link_field_options", + "is_login_required", + "remove_form_access", + "set_form_permission", +] diff --git a/forms_pro/api/form.py b/forms_pro/api/form/endpoints.py similarity index 67% rename from forms_pro/api/form.py rename to forms_pro/api/form/endpoints.py index 6e73a5c..e9efd0e 100644 --- a/forms_pro/api/form.py +++ b/forms_pro/api/form/endpoints.py @@ -1,20 +1,13 @@ import frappe from frappe import _ from frappe.share import add_docshare, remove -from pydantic import BaseModel, Field from forms_pro.api.user import get_user from forms_pro.forms_pro.doctype.form.form import Form +from forms_pro.utils.constants import FORMS_PRO_SYSTEM_FIELDNAMES, UNSUPPORTED_FRAPPE_FIELDTYPES +from forms_pro.utils.permissions import require_permission - -class FormSharedWithResponse(BaseModel): - full_name: str - user_image: str | None - email: str = Field(alias="user") - read: bool - write: bool - share: bool - submit: bool +from .schema import FormSharedWithResponse @frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method @@ -36,9 +29,11 @@ def is_login_required(route: str) -> bool: return bool(login_enabled) -@frappe.whitelist(allow_guest=True) +@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method def get_form_by_route(route: str) -> dict: form_id = frappe.db.get_value("Form", {"route": route}, pluck="name") + if not form_id: + frappe.throw(_("Form not found"), frappe.DoesNotExistError) return get_form(form_id) @@ -62,6 +57,27 @@ def get_form(form_id: str) -> dict: } +@frappe.whitelist() +@require_permission("Form", "read", param="form_id") +def get_form_for_view(form_id: str) -> dict: + """Return the form document for the Manage Form page. + + Requires ``read`` permission on the Form. Returns HTTP 404 when the + form does not exist and HTTP 403 when the user lacks permission. + """ + return get_form(form_id) + + +@frappe.whitelist() +@require_permission("Form", "write", param="form_id") +def get_form_for_edit(form_id: str) -> dict: + """Return the form document for the Edit Form page. + + Requires ``write`` permission on the Form. Returns HTTP 404/403 accordingly. + """ + return get_form(form_id) + + @frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method def get_link_field_options( doctype: str, @@ -81,19 +97,13 @@ def get_link_field_options( @frappe.whitelist() +@require_permission("Form", "read", param="form_id") def get_form_shared_with(form_id: str) -> list[frappe.Any]: - """ - Get list of users with which a form is shared. + """Get list of users with whom a form is shared. - We validate the current user has read access to the form. + Requires ``read`` permission on the Form (HTTP 403 otherwise, HTTP 404 + when the form does not exist). """ - if not frappe.has_permission( - "Form", - "read", - form_id, - ): - frappe.throw(_("You do not have read access to this form")) - form: Form = frappe.get_doc("Form", form_id) shared_with = form.shared_with() @@ -110,25 +120,22 @@ def get_form_shared_with(form_id: str) -> list[frappe.Any]: @frappe.whitelist() +@require_permission("Form", "write", param="form_id") def remove_form_access(form_id: str, user_email: str) -> None: - """ - Remove access to a form for a user. - - We validate the current user has write access to the form. + """Remove access to a form for a user. - args: - form_id: str - The ID of the form to remove access to. - user_email: str - The email of the user to remove access to. + Requires ``write`` permission on the Form (HTTP 403 otherwise, HTTP 404 + when the form does not exist). + Args: + form_id: The ID of the form to remove access from. + user_email: The email of the user whose access is being removed. """ - - if not frappe.has_permission("Form", "write", form_id): - frappe.throw(_("You do not have write access to this form")) - return remove(doctype="Form", name=form_id, user=user_email, flags={"ignore_permissions": True}) @frappe.whitelist() +@require_permission("Form", "share", param="form_id") def add_form_access( form_id: str, user: str, @@ -137,13 +144,12 @@ def add_form_access( share: bool = False, submit: bool = False, ) -> None: - """ - Grant a user access to a form with the specified permissions. + """Grant a user access to a form with the specified permissions. Uses ``ignore_share_permission`` so the record can be shared regardless of - the caller's role-level DocShare permissions — the explicit - ``frappe.has_permission`` check below enforces that only users with share - access on this particular form can invoke this endpoint. + the caller's role-level DocShare permissions — the ``@require_permission`` + decorator enforces that only users with share access on this particular form + can invoke this endpoint. Args: form_id: Name of the Form document to share. @@ -154,12 +160,9 @@ def add_form_access( submit: Allow the user to submit the form (default False). Raises: - frappe.PermissionError: If the calling user does not have share access - on the specified form. + frappe.PermissionError: HTTP 403, when the caller lacks share access. + frappe.DoesNotExistError: HTTP 404, when the form does not exist. """ - if not frappe.has_permission("Form", "share", form_id): - frappe.throw(_("You do not have share access to this form"), frappe.PermissionError) - add_docshare( doctype="Form", name=form_id, @@ -173,14 +176,14 @@ def add_form_access( @frappe.whitelist() +@require_permission("Form", "share", param="form_id") def set_form_permission( form_id: str, user: str, permission_to: str, value: bool, ) -> None: - """ - Toggle a single permission bit for a user on a form. + """Toggle a single permission bit for a user on a form. Designed for per-toggle updates from the sharing UI — only the specified permission field is changed; all other existing permissions are preserved by @@ -194,14 +197,11 @@ def set_form_permission( value: ``True`` to grant the permission, ``False`` to revoke it. Raises: - frappe.PermissionError: If the calling user does not have share access - on the specified form. + frappe.PermissionError: HTTP 403, when the caller lacks share access. + frappe.DoesNotExistError: HTTP 404, when the form does not exist. frappe.ValidationError: If ``permission_to`` is not a recognised permission type. """ - if not frappe.has_permission("Form", "share", form_id): - frappe.throw(_("You do not have share access to this form"), frappe.PermissionError) - # Guard against arbitrary kwargs being forwarded to add_docshare allowed_permissions = {"read", "write", "share", "submit"} if permission_to not in allowed_permissions: @@ -231,24 +231,12 @@ def get_doctype_list() -> list[str]: @frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method -def get_doctype_fields(doctype: str) -> dict: +def get_doctype_fields(doctype: str) -> list: doctype = frappe.get_doc("DocType", doctype) - fields = doctype.fields - - FIELDTYPES_TO_REMOVE = [ - "Section Break", - "HTML", - "Button", - "Column Break", - "Tab Break", - "Barcode", - "Dynamic Link", - "Fold", + fields = [ + field + for field in doctype.fields + if field.fieldtype not in UNSUPPORTED_FRAPPE_FIELDTYPES + and field.fieldname not in FORMS_PRO_SYSTEM_FIELDNAMES ] - - FIELDS_TO_REMOVE = ["fp_submission_status", "fp_linked_form"] - - fields = [field for field in fields if field.fieldtype not in FIELDTYPES_TO_REMOVE] - fields = [field for field in fields if field.fieldname not in FIELDS_TO_REMOVE] - return fields diff --git a/forms_pro/api/form/schema.py b/forms_pro/api/form/schema.py new file mode 100644 index 0000000..8a4fd5e --- /dev/null +++ b/forms_pro/api/form/schema.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, Field + + +class FormSharedWithResponse(BaseModel): + full_name: str + user_image: str | None + email: str = Field(alias="user") + read: bool + write: bool + share: bool + submit: bool diff --git a/forms_pro/api/form/test_form.py b/forms_pro/api/form/test_form.py new file mode 100644 index 0000000..2661136 --- /dev/null +++ b/forms_pro/api/form/test_form.py @@ -0,0 +1,193 @@ +import frappe +from frappe.share import add_docshare +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase + +from forms_pro.api.form import ( + add_form_access, + get_form_for_edit, + get_form_for_view, + get_form_shared_with, + remove_form_access, + set_form_permission, +) +from forms_pro.tests import FORMS_PRO_TEST_USER +from forms_pro.tests.factories.form_factory import FormFactory + + +class TestGetFormForView(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_returns_form_when_user_has_read(self) -> None: + result = get_form_for_view(form_id=self.form.name) + self.assertEqual(result["name"], self.form.name) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_form_for_view(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_form_for_view(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetFormForEdit(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_returns_form_when_user_has_write(self) -> None: + result = get_form_for_edit(form_id=self.form.name) + self.assertEqual(result["name"], self.form.name) + + def test_raises_403_when_user_has_read_but_not_write(self) -> None: + # Share read-only with the low-privilege user, then assert write is denied. + add_docshare( + doctype="Form", + name=self.form.name, + user=FORMS_PRO_TEST_USER, + read=1, + write=0, + share=0, + flags={"ignore_share_permission": True}, + ) + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_form_for_edit(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_form_for_edit(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetFormSharedWith(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_list_shares(self) -> None: + result = get_form_shared_with(form_id=self.form.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_form_shared_with(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_form_shared_with(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestAddFormAccess(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_add_access(self) -> None: + add_form_access( + form_id=self.form.name, + user=FORMS_PRO_TEST_USER, + read=True, + ) + self.assertTrue( + frappe.db.exists( + "DocShare", + {"share_doctype": "Form", "share_name": self.form.name, "user": FORMS_PRO_TEST_USER}, + ) + ) + + def test_raises_403_when_user_lacks_share(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + add_form_access(form_id=self.form.name, user="x@y.z", read=True) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + add_form_access(form_id="MISSING_FORM_XYZ", user=FORMS_PRO_TEST_USER, read=True) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestRemoveFormAccess(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + add_docshare( + doctype="Form", + name=self.form.name, + user=FORMS_PRO_TEST_USER, + read=1, + flags={"ignore_share_permission": True}, + ) + + def test_admin_can_remove_access(self) -> None: + remove_form_access(form_id=self.form.name, user_email=FORMS_PRO_TEST_USER) + self.assertFalse( + frappe.db.exists( + "DocShare", + {"share_doctype": "Form", "share_name": self.form.name, "user": FORMS_PRO_TEST_USER}, + ) + ) + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + remove_form_access(form_id=self.form.name, user_email=FORMS_PRO_TEST_USER) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + remove_form_access(form_id="MISSING_FORM_XYZ", user_email=FORMS_PRO_TEST_USER) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestSetFormPermission(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + add_docshare( + doctype="Form", + name=self.form.name, + user=FORMS_PRO_TEST_USER, + read=1, + flags={"ignore_share_permission": True}, + ) + + def test_admin_can_set_permission(self) -> None: + set_form_permission( + form_id=self.form.name, + user=FORMS_PRO_TEST_USER, + permission_to="write", + value=True, + ) + share = frappe.get_value( + "DocShare", + {"share_doctype": "Form", "share_name": self.form.name, "user": FORMS_PRO_TEST_USER}, + ["write"], + as_dict=True, + ) + self.assertEqual(share["write"], 1) + + def test_raises_403_when_user_lacks_share(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + set_form_permission( + form_id=self.form.name, + user=FORMS_PRO_TEST_USER, + permission_to="write", + value=True, + ) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + set_form_permission( + form_id="MISSING_FORM_XYZ", + user=FORMS_PRO_TEST_USER, + permission_to="write", + value=True, + ) + self.assertEqual(ctx.exception.http_status_code, 404) diff --git a/forms_pro/api/settings/__init__.py b/forms_pro/api/settings/__init__.py new file mode 100644 index 0000000..7b65385 --- /dev/null +++ b/forms_pro/api/settings/__init__.py @@ -0,0 +1,3 @@ +from .endpoints import get_brand_logo, get_website_settings + +__all__ = ["get_brand_logo", "get_website_settings"] diff --git a/forms_pro/api/settings.py b/forms_pro/api/settings/endpoints.py similarity index 100% rename from forms_pro/api/settings.py rename to forms_pro/api/settings/endpoints.py diff --git a/forms_pro/api/submission/__init__.py b/forms_pro/api/submission/__init__.py new file mode 100644 index 0000000..f3fe576 --- /dev/null +++ b/forms_pro/api/submission/__init__.py @@ -0,0 +1,15 @@ +from .endpoints import ( + get_all_submissions, + get_submission, + get_submission_response, + get_user_submissions, + submit_form_response, +) + +__all__ = [ + "get_all_submissions", + "get_submission", + "get_submission_response", + "get_user_submissions", + "submit_form_response", +] diff --git a/forms_pro/api/submission.py b/forms_pro/api/submission/endpoints.py similarity index 83% rename from forms_pro/api/submission.py rename to forms_pro/api/submission/endpoints.py index f35bf65..392d93d 100644 --- a/forms_pro/api/submission.py +++ b/forms_pro/api/submission/endpoints.py @@ -1,37 +1,16 @@ import json -from datetime import datetime from typing import Any import frappe from frappe import _ from frappe.share import add_docshare -from pydantic import BaseModel, Field, field_validator from forms_pro.forms_pro.doctype.form.form import Form from forms_pro.forms_pro.doctype.form_field.form_field import _DISPLAY_ONLY_FIELDTYPES from forms_pro.utils.form_generator import SubmissionStatus +from forms_pro.utils.permissions import require_permission - -class UserSubmissionResponse(BaseModel): - name: str = Field(description="Name of the submission") - creation: datetime = Field(description="Creation date of the submission") - modified: datetime = Field(description="Last modified date of the submission") - submission_status: str = Field( - description="Status of the submission", - alias="fp_submission_status", - default=SubmissionStatus.SUBMITTED.value, - ) - owner: str = Field(description="Owner of the submission") - - @field_validator("creation", "modified", mode="before") - @classmethod - def parse_datetime(cls, v: Any) -> datetime: - """Convert datetime string to datetime object.""" - if isinstance(v, str): - return frappe.utils.get_datetime(v) - if isinstance(v, datetime): - return v - raise ValueError(f"Invalid datetime value: {v}") +from .schema import UserSubmissionResponse def _coerce_field_value(value: Any, fieldtype: str) -> Any: @@ -128,7 +107,7 @@ def _validate_form_response(form: "Form", form_data: dict) -> None: def submit_form_response( form_id: str, form_data: list[dict], - submission_status: SubmissionStatus = SubmissionStatus.SUBMITTED, + submission_status: str = SubmissionStatus.SUBMITTED.value, ) -> str: """ Submit a form response @@ -141,6 +120,14 @@ def submit_form_response( Returns: The name of the submission """ + try: + status = SubmissionStatus(submission_status) + except ValueError: + frappe.throw( + _("Invalid submission status: {0}").format(submission_status), + frappe.ValidationError, + ) + try: form: Form = frappe.get_doc("Form", form_id) linked_doctype = form.linked_doctype @@ -151,18 +138,27 @@ def submit_form_response( frappe.PermissionError, ) + form_data_dict = {item["fieldname"]: item["value"] for item in form_data} + + # Whitelist to declared form fields only — prevents injecting system fields + # like owner, docstatus, etc. into the linked DocType. + allowed_fieldnames = {f.fieldname for f in form.fields} + form_data_dict = {k: v for k, v in form_data_dict.items() if k in allowed_fieldnames} + + if status == SubmissionStatus.SUBMITTED: + _validate_form_response(form, form_data_dict) + submission = frappe.new_doc(linked_doctype) - for data in form_data: - value = data["value"] + for fieldname, value in form_data_dict.items(): # JSON fields (e.g. Multiselect) must be stored as a JSON string, # but Frappe deserializes request body arrays into Python lists before # we get here — serialize them back. if isinstance(value, list): value = json.dumps(value) - submission.set(data["fieldname"], value) + submission.set(fieldname, value) submission.fp_linked_form = form_id - submission.fp_submission_status = submission_status.value + submission.fp_submission_status = status.value submission.insert(ignore_permissions=True, ignore_mandatory=True) # Share the submission with the owner @@ -183,6 +179,7 @@ def submit_form_response( @frappe.whitelist() +@require_permission("Form", "read", param="form_id") def get_user_submissions(form_id: str) -> list[UserSubmissionResponse]: """ Get the submissions for a user @@ -193,10 +190,6 @@ def get_user_submissions(form_id: str) -> list[UserSubmissionResponse]: Returns: A list of submissions for the user """ - - if frappe.session.user == "Guest": - return [] - form: Form = frappe.get_doc("Form", form_id) linked_doctype = form.linked_doctype @@ -267,6 +260,7 @@ def get_submission(submission_doctype: str, submission_name: str) -> dict[str, A @frappe.whitelist() +@require_permission("Form", "read", param="form_id") def get_all_submissions(form_id: str) -> list[UserSubmissionResponse]: """ Get all submissions for a form @@ -277,17 +271,6 @@ def get_all_submissions(form_id: str) -> list[UserSubmissionResponse]: Returns: A list of submissions for the form """ - linked_team = frappe.db.get_value("Form", form_id, "linked_team_id") - - if not linked_team: - frappe.throw(_("Form not found."), frappe.DoesNotExistError) - - if not frappe.has_permission(doctype="FP Team", ptype="write", doc=linked_team, user=frappe.session.user): - frappe.throw( - _("You do not have permission to read this form's submissions."), - frappe.PermissionError, - ) - form: Form = frappe.get_doc("Form", form_id) linked_doctype = form.linked_doctype diff --git a/forms_pro/api/submission/schema.py b/forms_pro/api/submission/schema.py new file mode 100644 index 0000000..e6f37c5 --- /dev/null +++ b/forms_pro/api/submission/schema.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Any + +import frappe +from pydantic import BaseModel, Field, field_validator + +from forms_pro.utils.form_generator import SubmissionStatus + + +class UserSubmissionResponse(BaseModel): + name: str = Field(description="Name of the submission") + creation: datetime = Field(description="Creation date of the submission") + modified: datetime = Field(description="Last modified date of the submission") + submission_status: str = Field( + description="Status of the submission", + alias="fp_submission_status", + default=SubmissionStatus.SUBMITTED.value, + ) + owner: str = Field(description="Owner of the submission") + + @field_validator("creation", "modified", mode="before") + @classmethod + def parse_datetime(cls, v: Any) -> datetime: + """Convert datetime string to datetime object.""" + if isinstance(v, str): + return frappe.utils.get_datetime(v) + if isinstance(v, datetime): + return v + raise ValueError(f"Invalid datetime value: {v}") diff --git a/forms_pro/api/submission/test_submission.py b/forms_pro/api/submission/test_submission.py new file mode 100644 index 0000000..5ffe48b --- /dev/null +++ b/forms_pro/api/submission/test_submission.py @@ -0,0 +1,46 @@ +import frappe +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase + +from forms_pro.api.submission import get_all_submissions, get_user_submissions +from forms_pro.tests import FORMS_PRO_TEST_USER +from forms_pro.tests.factories.form_factory import FormFactory + + +class TestGetUserSubmissions(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_list_user_submissions(self) -> None: + result = get_user_submissions(form_id=self.form.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_user_submissions(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_user_submissions(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetAllSubmissions(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_list_all_submissions(self) -> None: + result = get_all_submissions(form_id=self.form.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_all_submissions(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_all_submissions(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) diff --git a/forms_pro/api/submission/test_submission_validation.py b/forms_pro/api/submission/test_submission_validation.py new file mode 100644 index 0000000..320f71d --- /dev/null +++ b/forms_pro/api/submission/test_submission_validation.py @@ -0,0 +1,245 @@ +# Copyright (c) 2025, harsh@buildwithhussain.com and contributors +# For license information, please see license.txt + +import json +import unittest +from types import SimpleNamespace + +from forms_pro.api.submission.endpoints import ( + _coerce_field_value, + _evaluate_conditions, + _validate_form_response, +) +from forms_pro.forms_pro.doctype.form_field.form_field import _DISPLAY_ONLY_FIELDTYPES +from forms_pro.utils.constants import FORMS_PRO_SYSTEM_FIELDNAMES, UNSUPPORTED_FRAPPE_FIELDTYPES +from forms_pro.utils.form_generator import LINKED_FORM_FIELDOPTIONS, SUBMISSION_STATUS_FIELDOPTIONS + + +def _field(fieldname, fieldtype="Data", label=None, reqd=0, hidden=0, conditional_logic=None): + """Build a minimal field stub matching what _validate_form_response expects.""" + return SimpleNamespace( + fieldname=fieldname, + fieldtype=fieldtype, + label=label or fieldname.replace("_", " ").title(), + reqd=reqd, + hidden=hidden, + conditional_logic=conditional_logic, + ) + + +def _form(*fields): + """Build a minimal form stub.""" + return SimpleNamespace(fields=list(fields)) + + +class TestCoerceFieldValue(unittest.TestCase): + def test_empty_string_returns_none(self): + self.assertIsNone(_coerce_field_value("", "Data")) + + def test_none_returns_none(self): + self.assertIsNone(_coerce_field_value(None, "Data")) + + def test_switch_coerces_to_bool(self): + self.assertTrue(_coerce_field_value(1, "Switch")) + self.assertFalse(_coerce_field_value(0, "Switch")) + self.assertFalse(_coerce_field_value("", "Switch")) + + def test_checkbox_coerces_to_bool(self): + self.assertTrue(_coerce_field_value("true", "Checkbox")) + self.assertFalse(_coerce_field_value(0, "Checkbox")) + + def test_number_coerces_to_float(self): + self.assertEqual(_coerce_field_value("42", "Number"), 42.0) + self.assertEqual(_coerce_field_value(7, "Number"), 7.0) + self.assertEqual(_coerce_field_value("3.14", "Number"), 3.14) + self.assertEqual(_coerce_field_value(3.14, "Number"), 3.14) + + def test_number_invalid_returns_none(self): + self.assertIsNone(_coerce_field_value("abc", "Number")) + + def test_data_returns_string(self): + self.assertEqual(_coerce_field_value("hello", "Data"), "hello") + self.assertEqual(_coerce_field_value(123, "Data"), "123") + + +class TestEvaluateConditions(unittest.TestCase): + def _field_map(self, *fields): + return {f.fieldname: f for f in fields} + + def test_is_operator_match(self): + fields = self._field_map(_field("status", "Data")) + conditions = [{"fieldname": "status", "operator": "Is", "value": "Active"}] + self.assertTrue(_evaluate_conditions(conditions, {"status": "Active"}, fields)) + self.assertFalse(_evaluate_conditions(conditions, {"status": "Inactive"}, fields)) + + def test_is_not_operator(self): + fields = self._field_map(_field("status", "Data")) + conditions = [{"fieldname": "status", "operator": "Is Not", "value": "Active"}] + self.assertTrue(_evaluate_conditions(conditions, {"status": "Inactive"}, fields)) + self.assertFalse(_evaluate_conditions(conditions, {"status": "Active"}, fields)) + + def test_is_empty_operator(self): + fields = self._field_map(_field("note", "Data")) + conditions = [{"fieldname": "note", "operator": "Is Empty", "value": None}] + self.assertTrue(_evaluate_conditions(conditions, {"note": ""}, fields)) + self.assertTrue(_evaluate_conditions(conditions, {"note": None}, fields)) + self.assertFalse(_evaluate_conditions(conditions, {"note": "some text"}, fields)) + + def test_is_not_empty_operator(self): + fields = self._field_map(_field("note", "Data")) + conditions = [{"fieldname": "note", "operator": "Is Not Empty", "value": None}] + self.assertTrue(_evaluate_conditions(conditions, {"note": "hi"}, fields)) + self.assertFalse(_evaluate_conditions(conditions, {"note": ""}, fields)) + + def test_all_conditions_must_pass(self): + """AND logic — all conditions must be true.""" + fields = self._field_map(_field("a", "Data"), _field("b", "Data")) + conditions = [ + {"fieldname": "a", "operator": "Is", "value": "yes"}, + {"fieldname": "b", "operator": "Is", "value": "yes"}, + ] + self.assertTrue(_evaluate_conditions(conditions, {"a": "yes", "b": "yes"}, fields)) + self.assertFalse(_evaluate_conditions(conditions, {"a": "yes", "b": "no"}, fields)) + + def test_unknown_field_returns_false(self): + conditions = [{"fieldname": "nonexistent", "operator": "Is", "value": "x"}] + self.assertFalse(_evaluate_conditions(conditions, {}, {})) + + def test_number_condition_matches_float(self): + """float("3.14") must equal float(3.14) — old int() coercion would have failed.""" + fields = self._field_map(_field("score", "Number")) + conditions = [{"fieldname": "score", "operator": "Is", "value": 3.14}] + self.assertTrue(_evaluate_conditions(conditions, {"score": "3.14"}, fields)) + self.assertFalse(_evaluate_conditions(conditions, {"score": "3.15"}, fields)) + + def test_number_condition_int_vs_float_parity(self): + """Condition value stored as int 42 must match submitted value "42".""" + fields = self._field_map(_field("age", "Number")) + conditions = [{"fieldname": "age", "operator": "Is", "value": 42}] + self.assertTrue(_evaluate_conditions(conditions, {"age": "42"}, fields)) + + def test_boolean_condition_type_aware(self): + """True (bool) condition must match truthy submitted value without str() mismatch.""" + fields = self._field_map(_field("agreed", "Switch")) + conditions = [{"fieldname": "agreed", "operator": "Is", "value": True}] + self.assertTrue(_evaluate_conditions(conditions, {"agreed": 1}, fields)) + self.assertFalse(_evaluate_conditions(conditions, {"agreed": 0}, fields)) + + +class TestValidateFormResponse(unittest.TestCase): + def test_required_field_missing_raises(self): + import frappe + + form = _form(_field("name", reqd=1)) + with self.assertRaises(frappe.ValidationError): + _validate_form_response(form, {}) + + def test_required_field_present_passes(self): + form = _form(_field("name", reqd=1)) + _validate_form_response(form, {"name": "John"}) # must not raise + + def test_hidden_required_field_is_skipped(self): + """A hidden required field must not be validated.""" + form = _form(_field("secret", reqd=1, hidden=1)) + _validate_form_response(form, {}) # must not raise + + def test_conditional_show_makes_field_required(self): + """A field conditionally required via require_answer must be checked.""" + import frappe + + logic = json.dumps( + { + "target_field": "phone", + "action": "Require Answer", + "conditions": [{"fieldname": "contact_method", "operator": "Is", "value": "Phone"}], + } + ) + contact = _field("contact_method") + phone = _field("phone", reqd=0) + phone_with_logic = _field("contact_method", conditional_logic=logic) + + form = _form(contact, phone, phone_with_logic) + with self.assertRaises(frappe.ValidationError): + _validate_form_response(form, {"contact_method": "Phone", "phone": ""}) + + def test_conditional_hide_skips_required_field(self): + """A conditionally hidden field must not be validated even if reqd=1.""" + logic = json.dumps( + { + "target_field": "extra", + "action": "Hide Field", + "conditions": [{"fieldname": "flag", "operator": "Is", "value": "yes"}], + } + ) + flag = _field("flag") + extra = _field("extra", reqd=1) + flag_with_logic = _field("flag", conditional_logic=logic) + + form = _form(flag, extra, flag_with_logic) + # extra is required but should be hidden when flag=yes — no error expected + _validate_form_response(form, {"flag": "yes", "extra": ""}) + + def test_multiple_missing_fields_all_reported(self): + """All missing required fields should appear in the error, not just the first.""" + import frappe + + form = _form( + _field("first_name", reqd=1), + _field("last_name", reqd=1), + ) + with self.assertRaises(frappe.ValidationError) as ctx: + _validate_form_response(form, {}) + + error_msg = str(ctx.exception) + self.assertIn("First Name", error_msg) + self.assertIn("Last Name", error_msg) + + def test_required_field_with_zero_passes(self): + """Numeric 0 is a valid value and must not trigger a required-field error.""" + form = _form(_field("score", fieldtype="Number", reqd=1)) + _validate_form_response(form, {"score": 0}) # must not raise + + def test_required_field_with_false_passes(self): + """Boolean False (unchecked Switch) is a valid value for a required field.""" + form = _form(_field("agreed", fieldtype="Switch", reqd=1)) + _validate_form_response(form, {"agreed": False}) # must not raise + + +class TestConstants(unittest.TestCase): + def test_system_fieldnames_derived_from_form_generator(self): + """Constants must stay in sync with form_generator field definitions.""" + self.assertIn(SUBMISSION_STATUS_FIELDOPTIONS["fieldname"], FORMS_PRO_SYSTEM_FIELDNAMES) + self.assertIn(LINKED_FORM_FIELDOPTIONS["fieldname"], FORMS_PRO_SYSTEM_FIELDNAMES) + + def test_unsupported_fieldtypes_contains_layout_types(self): + for fieldtype in ("Section Break", "Column Break", "Tab Break", "Fold"): + self.assertIn(fieldtype, UNSUPPORTED_FRAPPE_FIELDTYPES) + + def test_unsupported_fieldtypes_contains_non_input_types(self): + for fieldtype in ("HTML", "Button", "Barcode", "Dynamic Link"): + self.assertIn(fieldtype, UNSUPPORTED_FRAPPE_FIELDTYPES) + + +class TestHeadingFieldValidation(unittest.TestCase): + def test_all_heading_levels_skipped_even_when_required(self): + """reqd=1 heading fields must never trigger a validation error.""" + for fieldtype in ("Heading 1", "Heading 2", "Heading 3"): + with self.subTest(fieldtype=fieldtype): + form = _form(_field("h", fieldtype=fieldtype, reqd=1)) + _validate_form_response(form, {}) # must not raise + + def test_required_data_field_still_validated_when_heading_present(self): + """Heading fields being skipped must not suppress validation of adjacent required fields.""" + import frappe + + form = _form( + _field("section_title", fieldtype="Heading 1", reqd=1), + _field("full_name", fieldtype="Data", reqd=1), + ) + with self.assertRaises(frappe.ValidationError) as ctx: + _validate_form_response(form, {}) + self.assertIn("Full Name", str(ctx.exception)) + + def test_display_only_fieldtypes_contains_all_heading_levels(self): + for fieldtype in ("Heading 1", "Heading 2", "Heading 3"): + self.assertIn(fieldtype, _DISPLAY_ONLY_FIELDTYPES) diff --git a/forms_pro/api/team/__init__.py b/forms_pro/api/team/__init__.py new file mode 100644 index 0000000..b64ac86 --- /dev/null +++ b/forms_pro/api/team/__init__.py @@ -0,0 +1,25 @@ +from .endpoints import ( + add_member_to_team_via_invitation, + create_team, + get_team_for_manage, + get_team_forms, + get_team_members, + invite_team_members, + remove_member_from_team, + save, + switch_team, + toggle_can_edit_team, +) + +__all__ = [ + "add_member_to_team_via_invitation", + "create_team", + "get_team_for_manage", + "get_team_forms", + "get_team_members", + "invite_team_members", + "remove_member_from_team", + "save", + "switch_team", + "toggle_can_edit_team", +] diff --git a/forms_pro/api/team.py b/forms_pro/api/team/endpoints.py similarity index 76% rename from forms_pro/api/team.py rename to forms_pro/api/team/endpoints.py index 574795b..96cc253 100644 --- a/forms_pro/api/team.py +++ b/forms_pro/api/team/endpoints.py @@ -5,6 +5,7 @@ from frappe.share import get_share_name from forms_pro.forms_pro.doctype.fp_team.fp_team import FPTeam, GetTeamMembersResponse +from forms_pro.utils.permissions import require_permission from forms_pro.utils.teams import ( GetTeamFormsResponseSchema, set_current_team, @@ -14,6 +15,22 @@ ) +@frappe.whitelist() +@require_permission("FP Team", "read", param="team_id") +def get_team_for_manage(team_id: str) -> dict: + """Return team details for the Manage Team page. + + Requires ``read`` permission on the FP Team. Returns HTTP 404/403 + accordingly. + """ + team: FPTeam = frappe.get_doc("FP Team", team_id) + return { + "name": team.name, + "team_name": team.team_name, + "logo": team.logo, + } + + @frappe.whitelist() def get_team_forms(team_id: str) -> list[GetTeamFormsResponseSchema]: """ @@ -30,21 +47,13 @@ def get_team_forms(team_id: str) -> list[GetTeamFormsResponseSchema]: @frappe.whitelist() +@require_permission("FP Team", "read", param="team_id") def get_team_members(team_id: str) -> list[GetTeamMembersResponse]: - """ - - Get the list of team members in a FP Team. - This endpoint checks if the session user has the permission to read this FP Team DocType + """Get the list of team members in a FP Team. + Requires ``read`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). """ - frappe.has_permission( - doctype="FP Team", - ptype="read", - doc=team_id, - user=frappe.session.user, - throw=True, - ) - # Clear cache so we read fresh DocShare data (e.g. after toggle_can_edit_team) frappe.clear_document_cache("FP Team", team_id) @@ -92,21 +101,13 @@ def switch_team(team_id: str) -> None: @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def invite_team_members(team_id: str, emails: list[str]) -> None: - """ - Invite team members to a team - """ - - if not frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - ): - raise frappe.PermissionError( - "You do not have write permission on this team; write access is required to invite members" - ) + """Invite team members to a team. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ emails_str = ", ".join(emails) invite_by_email( @@ -157,21 +158,13 @@ def add_member_to_team_via_invitation(team_id: str, invite_id: str | None = None @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def toggle_can_edit_team(team_id: str, member_email: str) -> None: - """ - Toggle the can_edit_team permission for a team member - """ - - if not frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - ): - raise frappe.PermissionError( - "You do not have permission to toggle the can_edit_team permission for this team member" - ) + """Toggle the can_edit_team permission for a team member. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ team: FPTeam = frappe.get_doc("FP Team", team_id) if team.owner == member_email: raise frappe.PermissionError( @@ -191,18 +184,13 @@ def toggle_can_edit_team(team_id: str, member_email: str) -> None: @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def save(team_id: str, fields: dict) -> None: - """ - Update team fields. Only fields present in the dict are updated. - """ - frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - throw=True, - ) + """Update team fields. Only fields present in the dict are updated. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ ALLOWED_SAVE_FIELDS = ["team_name", "logo"] team: FPTeam = frappe.get_doc("FP Team", team_id) @@ -214,18 +202,12 @@ def save(team_id: str, fields: dict) -> None: @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def remove_member_from_team(team_id: str, member_email: str) -> None: - """ - Remove a member from a team - """ - - if not frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - ): - raise frappe.PermissionError("You do not have permission to remove a member from this team") + """Remove a member from a team. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ team: FPTeam = frappe.get_doc("FP Team", team_id) team.remove_from_team(member_email) diff --git a/forms_pro/tests/test_invitations.py b/forms_pro/api/team/test_invitations.py similarity index 100% rename from forms_pro/tests/test_invitations.py rename to forms_pro/api/team/test_invitations.py diff --git a/forms_pro/api/team/test_team.py b/forms_pro/api/team/test_team.py new file mode 100644 index 0000000..36993c1 --- /dev/null +++ b/forms_pro/api/team/test_team.py @@ -0,0 +1,129 @@ +import frappe +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase + +from forms_pro.api.team import ( + get_team_for_manage, + get_team_members, + invite_team_members, + remove_member_from_team, + save, + toggle_can_edit_team, +) +from forms_pro.tests.factories.fp_team_factory import FPTeamFactory +from forms_pro.tests.factories.user_factory import UserFactory + + +class TestGetTeamForManage(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + # Fresh user with no Forms Pro role → no doctype-level perm on FP Team. + self.outsider = UserFactory.create() + + def test_returns_team_when_user_has_read(self) -> None: + result = get_team_for_manage(team_id=self.team.name) + self.assertEqual(result["name"], self.team.name) + self.assertEqual(result["team_name"], self.team.team_name) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + get_team_for_manage(team_id=self.team.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_team_for_manage(team_id="MISSING_TEAM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetTeamMembers(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_admin_can_list_members(self) -> None: + result = get_team_members(team_id=self.team.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + get_team_members(team_id=self.team.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_team_members(team_id="MISSING_TEAM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestInviteTeamMembers(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + invite_team_members(team_id=self.team.name, emails=["x@y.z"]) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + invite_team_members(team_id="MISSING_TEAM_XYZ", emails=["x@y.z"]) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestToggleCanEditTeam(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + toggle_can_edit_team(team_id=self.team.name, member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + toggle_can_edit_team(team_id="MISSING_TEAM_XYZ", member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestSaveTeam(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_admin_can_save_team(self) -> None: + save(team_id=self.team.name, fields={"team_name": "Renamed"}) + self.assertEqual(frappe.db.get_value("FP Team", self.team.name, "team_name"), "Renamed") + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + save(team_id=self.team.name, fields={"team_name": "Hijacked"}) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + save(team_id="MISSING_TEAM_XYZ", fields={"team_name": "X"}) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestRemoveMemberFromTeam(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + remove_member_from_team(team_id=self.team.name, member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + remove_member_from_team(team_id="MISSING_TEAM_XYZ", member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 404) diff --git a/forms_pro/api/user/__init__.py b/forms_pro/api/user/__init__.py new file mode 100644 index 0000000..e81dc8b --- /dev/null +++ b/forms_pro/api/user/__init__.py @@ -0,0 +1,3 @@ +from .endpoints import get_current_user, get_user, get_user_teams + +__all__ = ["get_current_user", "get_user", "get_user_teams"] diff --git a/forms_pro/api/user.py b/forms_pro/api/user/endpoints.py similarity index 56% rename from forms_pro/api/user.py rename to forms_pro/api/user/endpoints.py index 20adfba..d98d11b 100644 --- a/forms_pro/api/user.py +++ b/forms_pro/api/user/endpoints.py @@ -1,40 +1,9 @@ import frappe -from frappe.core.doctype.has_role.has_role import HasRole from frappe.core.doctype.user.user import User -from pydantic import BaseModel, Field, field_validator from forms_pro.utils.teams import get_user_teams as get_user_teams_utils - -class GetUserTeamsResponseSchema(BaseModel): - name: str = Field(description="ID of the team") - team_name: str = Field(description="The name of the team") - logo: str | None = Field(description="Logo of the team") - is_current: bool = Field(description="Whether this is the current team") - - -class GetUserResponseSchema(BaseModel): - email: str - first_name: str - last_name: str | None = None - full_name: str - username: str - desk_theme: str - roles: list[str] - has_desk_access: bool - - @field_validator("roles", mode="before") - @classmethod - def extract_roles(cls, v: list[HasRole]) -> list[str]: - if not v: - return [] - - return [role.role for role in v] - - -class GetUserBasicResponse(BaseModel): - full_name: str - user_image: str | None = None +from .schema import GetUserBasicResponse, GetUserResponseSchema, GetUserTeamsResponseSchema @frappe.whitelist() diff --git a/forms_pro/api/user/schema.py b/forms_pro/api/user/schema.py new file mode 100644 index 0000000..77061ce --- /dev/null +++ b/forms_pro/api/user/schema.py @@ -0,0 +1,33 @@ +from frappe.core.doctype.has_role.has_role import HasRole +from pydantic import BaseModel, Field, field_validator + + +class GetUserTeamsResponseSchema(BaseModel): + name: str = Field(description="ID of the team") + team_name: str = Field(description="The name of the team") + logo: str | None = Field(description="Logo of the team") + is_current: bool = Field(description="Whether this is the current team") + + +class GetUserResponseSchema(BaseModel): + email: str + first_name: str + last_name: str | None = None + full_name: str + username: str + desk_theme: str + roles: list[str] + has_desk_access: bool + + @field_validator("roles", mode="before") + @classmethod + def extract_roles(cls, v: list[HasRole]) -> list[str]: + if not v: + return [] + + return [role.role for role in v] + + +class GetUserBasicResponse(BaseModel): + full_name: str + user_image: str | None = None diff --git a/forms_pro/api/user/test_user.py b/forms_pro/api/user/test_user.py new file mode 100644 index 0000000..1a353bb --- /dev/null +++ b/forms_pro/api/user/test_user.py @@ -0,0 +1,43 @@ +""" +Audit coverage for forms_pro.api.user. + +These endpoints gate on session state, not DocShare, so they take no +`@require_permission` decorator. The tests below document current +behavior so future regressions are caught. +""" + +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase + +from forms_pro.api.user import get_current_user, get_user, get_user_teams +from forms_pro.tests import FORMS_PRO_TEST_USER + + +class TestGetUser(IntegrationTestCase): + def test_returns_basic_payload_for_existing_user(self) -> None: + result = get_user(user=FORMS_PRO_TEST_USER) + self.assertIsNotNone(result) + self.assertIn("full_name", result) + self.assertIn("user_image", result) + + def test_returns_none_for_missing_user(self) -> None: + self.assertIsNone(get_user(user="missing_user_xyz@example.com")) + + +class TestGetCurrentUser(IntegrationTestCase): + def test_returns_session_user_payload(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + result = get_current_user() + self.assertEqual(result["email"], FORMS_PRO_TEST_USER) + self.assertIn("roles", result) + self.assertIn("has_desk_access", result) + + +class TestGetUserTeams(IntegrationTestCase): + def test_returns_list_for_real_user(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + result = get_user_teams() + self.assertIsInstance(result, list) + + def test_returns_empty_for_guest(self) -> None: + with self.set_user("Guest"): + self.assertEqual(get_user_teams(), []) diff --git a/forms_pro/forms_pro/doctype/form/test_form.py b/forms_pro/forms_pro/doctype/form/test_form.py index 5ffacac..e74ea5e 100644 --- a/forms_pro/forms_pro/doctype/form/test_form.py +++ b/forms_pro/forms_pro/doctype/form/test_form.py @@ -2,18 +2,19 @@ # See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase +from forms_pro.patches.v0_x.backfill_field_layout import execute as backfill_execute from forms_pro.utils.teams import get_user_teams -# On FrappeTestCase, the doctype test records and all +# On IntegrationTestCase, the doctype test records and all # link-field test record dependencies are recursively loaded # Use these module variables to add/remove to/from that list EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -class IntegrationTestForm(FrappeTestCase): +class IntegrationTestForm(IntegrationTestCase): def setUp(self): """Set up test data before each test method.""" # Create a test DocType for testing @@ -202,3 +203,104 @@ def test_field_with_options(self): self.assertEqual(status_field.fieldtype, "Select") self.assertEqual(status_field.options, "Active\nInactive\nPending") self.assertEqual(status_field.default, "Active") + + def test_layout_fields_do_not_affect_doctype_sync(self): + """Changing row_index/column_index on FormField must not modify the linked DocType.""" + self.test_form.append( + "fields", + {"label": "Full Name", "fieldname": "full_name", "fieldtype": "Data"}, + ) + self.test_form.save() + + doctype_doc = frappe.get_doc("DocType", self.test_doctype_name) + fields_before = [(f.fieldname, f.fieldtype, f.label) for f in doctype_doc.fields] + + # Mutate only layout fields and save + self.test_form.fields[0].row_index = 5 + self.test_form.fields[0].column_index = 3 + self.test_form.save() + + doctype_doc = frappe.get_doc("DocType", self.test_doctype_name) + fields_after = [(f.fieldname, f.fieldtype, f.label) for f in doctype_doc.fields] + + self.assertEqual(fields_before, fields_after) + + +class TestBackfillFieldLayoutPatch(IntegrationTestCase): + """Backfill patch sets row_index = idx-1, column_index = 0 for all FormField rows.""" + + _test_doctype_name = "Backfill Patch Test DocType" + + def setUp(self): + from forms_pro.tests import FORMS_PRO_TEST_USER + from forms_pro.utils.teams import get_user_teams + + if not frappe.db.exists("DocType", self._test_doctype_name): + frappe.get_doc( + { + "doctype": "DocType", + "name": self._test_doctype_name, + "module": "Custom", + "custom": 1, + "fields": [{"fieldname": "title", "fieldtype": "Data", "label": "Title"}], + } + ).insert() + + self.test_team = get_user_teams(FORMS_PRO_TEST_USER)[0]["name"] + + def tearDown(self): + for name in frappe.get_all("Form", filters={"linked_doctype": self._test_doctype_name}, pluck="name"): + frappe.delete_doc("Form", name, force=True, ignore_permissions=True) + if frappe.db.exists("DocType", self._test_doctype_name): + frappe.delete_doc("DocType", self._test_doctype_name, force=True) + + def _make_form(self) -> str: + form = frappe.get_doc( + { + "doctype": "Form", + "title": "Patch Test Form", + "linked_doctype": self._test_doctype_name, + "linked_team_id": self.test_team, + "fields": [ + {"label": "Field A", "fieldname": "field_a", "fieldtype": "Data"}, + {"label": "Field B", "fieldname": "field_b", "fieldtype": "Data"}, + {"label": "Field C", "fieldname": "field_c", "fieldtype": "Data"}, + ], + } + ) + form.insert(ignore_permissions=True) + return form.name + + def test_patch_sets_row_index_from_idx(self): + form_name = self._make_form() + frappe.db.sql( + "UPDATE `tabForm Field` SET row_index = 99, column_index = 99 WHERE parent = %s", + form_name, + ) + + backfill_execute() + + fields = frappe.get_all( + "Form Field", + filters={"parent": form_name}, + fields=["fieldname", "idx", "row_index", "column_index"], + order_by="idx asc", + ) + for f in fields: + self.assertEqual(f.row_index, f.idx - 1, f"row_index wrong for {f.fieldname}") + self.assertEqual(f.column_index, 0, f"column_index wrong for {f.fieldname}") + + def test_patch_is_idempotent(self): + form_name = self._make_form() + backfill_execute() + backfill_execute() + + fields = frappe.get_all( + "Form Field", + filters={"parent": form_name}, + fields=["idx", "row_index", "column_index"], + order_by="idx asc", + ) + for f in fields: + self.assertEqual(f.row_index, f.idx - 1) + self.assertEqual(f.column_index, 0) diff --git a/forms_pro/forms_pro/doctype/form_field/form_field.json b/forms_pro/forms_pro/doctype/form_field/form_field.json index 02a9a11..5c0f1fd 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.json +++ b/forms_pro/forms_pro/doctype/form_field/form_field.json @@ -6,6 +6,9 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "row_index", + "column_index", + "cell_index", "reqd", "hidden", "label", @@ -75,13 +78,34 @@ "fieldname": "hidden", "fieldtype": "Check", "label": "Hidden" + }, + { + "fieldname": "row_index", + "fieldtype": "Int", + "label": "Row Index", + "non_negative": 1, + "read_only": 1 + }, + { + "fieldname": "column_index", + "fieldtype": "Int", + "label": "Column Index", + "non_negative": 1, + "read_only": 1 + }, + { + "fieldname": "cell_index", + "fieldtype": "Int", + "label": "Cell Index", + "non_negative": 1, + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-25 20:32:10.994784", + "modified": "2026-05-05 00:02:48.020615", "modified_by": "Administrator", "module": "Forms Pro", "name": "Form Field", diff --git a/forms_pro/forms_pro/doctype/form_field/form_field.py b/forms_pro/forms_pro/doctype/form_field/form_field.py index 750aeb0..a1a1bc7 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.py +++ b/forms_pro/forms_pro/doctype/form_field/form_field.py @@ -2,6 +2,7 @@ # For license information, please see license.txt # import frappe +from frappe.model import no_value_fields from frappe.model.document import Document from frappe.utils import escape_html @@ -48,6 +49,8 @@ class FormField(Document): if TYPE_CHECKING: from frappe.types import DF + cell_index: DF.Int + column_index: DF.Int conditional_logic: DF.Code | None default: DF.SmallText | None description: DF.SmallText | None @@ -83,38 +86,29 @@ class FormField(Document): parentfield: DF.Data parenttype: DF.Data reqd: DF.Check + row_index: DF.Int # end: auto-generated types @property - def to_frappe_field(self) -> dict: - _fieldtype = self.fieldtype + def frappe_fieldtype(self) -> str: + """Resolved underlying Frappe fieldtype (post-mapping).""" + mapping = FORM_TO_FRAPPE_FIELDTYPE.get(self.fieldtype, {}) + return mapping.get("fieldtype", self.fieldtype) - if self.fieldtype == "Email": - _fieldtype = "Data" - self.options = "Email" - elif self.fieldtype == "Number": - _fieldtype = "Int" - elif self.fieldtype == "Date Time": - _fieldtype = "Datetime" - elif self.fieldtype == "Date Range": - _fieldtype = "Data" - elif self.fieldtype == "Time Picker": - _fieldtype = "Time" - elif self.fieldtype == "Switch" or self.fieldtype == "Checkbox": - _fieldtype = "Check" - elif self.fieldtype == "Textarea": - _fieldtype = "Text" - elif self.fieldtype == "Multiselect": - _fieldtype = "JSON" - elif self.fieldtype in _DISPLAY_ONLY_FIELDTYPES: - _fieldtype = "HTML" + @property + def stores_value(self) -> bool: + """False for display-only field types (Heading, etc.) that have no DB column.""" + return self.frappe_fieldtype not in no_value_fields + @property + def to_frappe_field(self) -> dict: + mapping = FORM_TO_FRAPPE_FIELDTYPE.get(self.fieldtype, {}) return { "fieldname": self.fieldname, - "fieldtype": _fieldtype, + "fieldtype": self.frappe_fieldtype, "label": self.label, "reqd": self.reqd, - "options": self.get_options(), + "options": mapping.get("options", self.get_options()), "description": self.description, "default": self.default, } diff --git a/forms_pro/tests/test_form_field.py b/forms_pro/forms_pro/doctype/form_field/test_form_field.py similarity index 69% rename from forms_pro/tests/test_form_field.py rename to forms_pro/forms_pro/doctype/form_field/test_form_field.py index 3fb88d7..b7767cd 100644 --- a/forms_pro/tests/test_form_field.py +++ b/forms_pro/forms_pro/doctype/form_field/test_form_field.py @@ -74,3 +74,34 @@ def test_form_to_frappe_fieldtype_has_all_heading_levels(self): with self.subTest(fieldtype=fieldtype): self.assertIn(fieldtype, FORM_TO_FRAPPE_FIELDTYPE) self.assertEqual(FORM_TO_FRAPPE_FIELDTYPE[fieldtype]["fieldtype"], "HTML") + + +class TestLayoutFieldsNotSyncedToDoctype(IntegrationTestCase): + """row_index, column_index, cell_index are layout-only and must never appear in to_frappe_field.""" + + def test_row_index_excluded_from_frappe_field(self): + field = _build_field("Data", label="Name", fieldname="name_field") + field.row_index = 3 + self.assertNotIn("row_index", field.to_frappe_field) + + def test_column_index_excluded_from_frappe_field(self): + field = _build_field("Data", label="Name", fieldname="name_field") + field.column_index = 2 + self.assertNotIn("column_index", field.to_frappe_field) + + def test_cell_index_excluded_from_frappe_field(self): + field = _build_field("Data", label="Name", fieldname="name_field") + field.cell_index = 4 + self.assertNotIn("cell_index", field.to_frappe_field) + + def test_layout_fields_excluded_for_all_fieldtypes(self): + for fieldtype in FORM_TO_FRAPPE_FIELDTYPE: + with self.subTest(fieldtype=fieldtype): + field = _build_field(fieldtype, label="Test", fieldname="test_f") + field.row_index = 1 + field.column_index = 1 + field.cell_index = 1 + result = field.to_frappe_field + self.assertNotIn("row_index", result) + self.assertNotIn("column_index", result) + self.assertNotIn("cell_index", result) diff --git a/forms_pro/forms_pro/doctype/fp_team/fp_team.py b/forms_pro/forms_pro/doctype/fp_team/fp_team.py index ac31f00..5a27f03 100644 --- a/forms_pro/forms_pro/doctype/fp_team/fp_team.py +++ b/forms_pro/forms_pro/doctype/fp_team/fp_team.py @@ -52,7 +52,7 @@ def team_members(self) -> list[GetTeamMembersResponse]: _user = get_user(member.user) _user["email"] = member.user share_name = get_share_name(doctype="FP Team", name=self.name, user=member.user, everyone=0) - _user["can_edit_team"] = frappe.db.get_value("DocShare", share_name, "write") + _user["can_edit_team"] = bool(share_name and frappe.db.get_value("DocShare", share_name, "write")) _user["is_owner"] = self.owner == member.user members.append(GetTeamMembersResponse.model_validate(_user).model_dump()) diff --git a/forms_pro/forms_pro/doctype/fp_team/test_fp_team.py b/forms_pro/forms_pro/doctype/fp_team/test_fp_team.py index f4efff6..3c3cfef 100644 --- a/forms_pro/forms_pro/doctype/fp_team/test_fp_team.py +++ b/forms_pro/forms_pro/doctype/fp_team/test_fp_team.py @@ -3,18 +3,18 @@ import frappe from frappe.defaults import get_user_default -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase from forms_pro.tests.factories import FPTeamFactory, UserFactory -# On FrappeTestCase, the doctype test records and all +# On IntegrationTestCase, the doctype test records and all # link-field test record dependencies are recursively loaded # Use these module variables to add/remove to/from that list EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -class IntegrationTestFPTeam(FrappeTestCase): +class IntegrationTestFPTeam(IntegrationTestCase): """ Integration tests for FPTeam. Use this class for testing interactions between multiple components. diff --git a/forms_pro/forms_pro/doctype/fp_team_member/test_fp_team_member.py b/forms_pro/forms_pro/doctype/fp_team_member/test_fp_team_member.py index 30ce31f..39e027d 100644 --- a/forms_pro/forms_pro/doctype/fp_team_member/test_fp_team_member.py +++ b/forms_pro/forms_pro/doctype/fp_team_member/test_fp_team_member.py @@ -2,16 +2,16 @@ # See license.txt # import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase -# On FrappeTestCase, the doctype test records and all +# On IntegrationTestCase, the doctype test records and all # link-field test record dependencies are recursively loaded # Use these module variables to add/remove to/from that list EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -class IntegrationTestFPTeamMember(FrappeTestCase): +class IntegrationTestFPTeamMember(IntegrationTestCase): """ Integration tests for FPTeamMember. Use this class for testing interactions between multiple components. diff --git a/forms_pro/patches.txt b/forms_pro/patches.txt index e944b5f..1ca1321 100644 --- a/forms_pro/patches.txt +++ b/forms_pro/patches.txt @@ -4,4 +4,5 @@ forms_pro.patches.0_1_beta.create_user_forms_module_def [post_model_sync] -# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file +# Patches added in this section will be executed after doctypes are migrated +forms_pro.patches.v0_x.backfill_field_layout \ No newline at end of file diff --git a/forms_pro/patches/v0_x/__init__.py b/forms_pro/patches/v0_x/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forms_pro/patches/v0_x/backfill_field_layout.py b/forms_pro/patches/v0_x/backfill_field_layout.py new file mode 100644 index 0000000..79b98d8 --- /dev/null +++ b/forms_pro/patches/v0_x/backfill_field_layout.py @@ -0,0 +1,19 @@ +import frappe + + +def execute(): + """ + Backfill row_index / column_index on existing FormField records. + Each field becomes its own single-column row, preserving vertical order. + Uses direct SQL to avoid triggering Form.on_update / set_doctype_fields(). + Idempotent: WHERE clause skips already-correct rows. + """ + # row_index / column_index on `tabForm Field` are Int NOT NULL DEFAULT 0 + # (Frappe's schema sync). Legacy rows pre-dating these columns therefore + # land on 0 after migration, not NULL — so a plain `!=` predicate is + # enough. No IS NULL branch needed. + frappe.db.sql(""" + UPDATE `tabForm Field` + SET row_index = idx - 1, column_index = 0 + WHERE row_index != idx - 1 OR column_index != 0 + """) diff --git a/forms_pro/tests/factories/form_factory.py b/forms_pro/tests/factories/form_factory.py new file mode 100644 index 0000000..c7da1be --- /dev/null +++ b/forms_pro/tests/factories/form_factory.py @@ -0,0 +1,35 @@ +from typing import Any + +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from forms_pro.forms_pro.doctype.form.form import Form + +_fake = Faker() + + +class FormFactory(BaseFactory[Form]): + """ + Builds a Form linked to a placeholder custom DocType (created via + `LinkedFormDoctypeFactory`) and a fresh `FP Team`. Pass + `linked_doctype=<name>` to attach to an existing DocType (e.g. for + multi-form scoping tests) and `linked_team_id=<name>` to bind to an + existing team. + """ + + doctype = "Form" + + @property + def default_attributes(self) -> dict[str, Any]: + from forms_pro.tests.factories.fp_team_factory import FPTeamFactory + from forms_pro.tests.factories.linked_form_doctype_factory import ( + LinkedFormDoctypeFactory, + ) + + return { + "title": _fake.unique.sentence(nb_words=3).rstrip("."), + "linked_doctype": ( + self.overrides.get("linked_doctype") or LinkedFormDoctypeFactory.create().name + ), + "linked_team_id": (self.overrides.get("linked_team_id") or FPTeamFactory.create().name), + } diff --git a/forms_pro/tests/factories/fp_team_factory.py b/forms_pro/tests/factories/fp_team_factory.py index 5981173..3f289b3 100644 --- a/forms_pro/tests/factories/fp_team_factory.py +++ b/forms_pro/tests/factories/fp_team_factory.py @@ -3,10 +3,12 @@ from faker import Faker from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory +from forms_pro.forms_pro.doctype.fp_team.fp_team import FPTeam + _fake = Faker() -class FPTeamFactory(BaseFactory): +class FPTeamFactory(BaseFactory[FPTeam]): doctype = "FP Team" @property diff --git a/forms_pro/tests/factories/linked_form_doctype_factory.py b/forms_pro/tests/factories/linked_form_doctype_factory.py new file mode 100644 index 0000000..4469555 --- /dev/null +++ b/forms_pro/tests/factories/linked_form_doctype_factory.py @@ -0,0 +1,57 @@ +from typing import Any + +from faker import Faker +from frappe.model.document import Document +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from forms_pro.utils.form_generator import LINKED_FORM_FIELDOPTIONS, SUBMISSION_STATUS_FIELDOPTIONS + +_fake = Faker() + + +class LinkedFormDoctypeFactory(BaseFactory[Document]): + """ + Builds a placeholder custom DocType that mirrors what + `forms_pro.utils.form_generator.FormGenerator` would create for a new + Form. The DocType carries `fp_submission_status` and `fp_linked_form` + so a Form pointing at it accepts submissions immediately. + + Use as the linked_doctype for `FormFactory`. + """ + + doctype = "DocType" + + @property + def default_attributes(self) -> dict[str, Any]: + name = f"formspro_test_{_fake.unique.lexify('??????').lower()}" + return { + "name": name, + "module": "User Forms", + "custom": 1, + "track_changes": 1, + "issingle": 0, + "istable": 0, + "is_submittable": 0, + "is_virtual": 0, + "editable_grid": 0, + "fields": [ + {"fieldtype": "Section Break"}, + SUBMISSION_STATUS_FIELDOPTIONS, + LINKED_FORM_FIELDOPTIONS, + ], + "permissions": [ + { + "role": "System Manager", + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "share": 1, + "write": 1, + "submit": 0, + } + ], + } diff --git a/forms_pro/tests/factories/user_factory.py b/forms_pro/tests/factories/user_factory.py index 28016ef..b6b8e01 100644 --- a/forms_pro/tests/factories/user_factory.py +++ b/forms_pro/tests/factories/user_factory.py @@ -1,6 +1,7 @@ from typing import Any from faker import Faker +from frappe.core.doctype.user.user import User from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory from forms_pro.roles import FORMS_PRO_ROLE @@ -8,7 +9,7 @@ _fake = Faker() -class UserFactory(BaseFactory): +class UserFactory(BaseFactory[User]): doctype = "User" @property diff --git a/forms_pro/tests/factories/user_invitation_factory.py b/forms_pro/tests/factories/user_invitation_factory.py index 2e74763..3b09df6 100644 --- a/forms_pro/tests/factories/user_invitation_factory.py +++ b/forms_pro/tests/factories/user_invitation_factory.py @@ -1,6 +1,7 @@ from typing import Any from faker import Faker +from frappe.core.doctype.user_invitation.user_invitation import UserInvitation from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory from forms_pro.roles import FORMS_PRO_ROLE @@ -8,7 +9,7 @@ _fake = Faker() -class UserInvitationFactory(BaseFactory): +class UserInvitationFactory(BaseFactory[UserInvitation]): doctype = "User Invitation" @property diff --git a/forms_pro/utils/constants.py b/forms_pro/utils/constants.py new file mode 100644 index 0000000..7675945 --- /dev/null +++ b/forms_pro/utils/constants.py @@ -0,0 +1,27 @@ +from forms_pro.utils.form_generator import LINKED_FORM_FIELDOPTIONS, SUBMISSION_STATUS_FIELDOPTIONS + +# Frappe fieldtypes that have no Forms Pro equivalent and must be excluded +# when importing fields from an existing DocType into a form. +# These are layout/structural types (Section Break, Column Break, etc.) +# or types with no meaningful input representation (Button, Barcode, Fold). +UNSUPPORTED_FRAPPE_FIELDTYPES: frozenset[str] = frozenset( + [ + "Section Break", + "Column Break", + "Tab Break", + "Fold", + "HTML", + "Button", + "Barcode", + "Dynamic Link", + ] +) + +# Fieldnames injected by Forms Pro itself — must never appear in the +# user-visible field list returned to the form builder or submission page. +FORMS_PRO_SYSTEM_FIELDNAMES: frozenset[str] = frozenset( + [ + SUBMISSION_STATUS_FIELDOPTIONS["fieldname"], # fp_submission_status + LINKED_FORM_FIELDOPTIONS["fieldname"], # fp_linked_form + ] +) diff --git a/forms_pro/utils/permissions.py b/forms_pro/utils/permissions.py new file mode 100644 index 0000000..5af1333 --- /dev/null +++ b/forms_pro/utils/permissions.py @@ -0,0 +1,46 @@ +import functools +from collections.abc import Callable + +import frappe +from frappe import _ + + +def require_permission(doctype: str, ptype: str = "read", param: str = "name") -> Callable: + """Enforce frappe.has_permission(doctype, ptype, kwargs[param]) before fn runs. + + Raises: + frappe.DoesNotExistError (HTTP 404): when ptype != "create" and the + referenced document does not exist. + frappe.PermissionError (HTTP 403): when the user lacks the permission. + + Args: + doctype: DocType to check against. + ptype: Permission type ("read", "write", "share", "delete", "create"). + param: Keyword-argument name carrying the docname. Ignored for "create". + """ + + def decorator(fn: Callable) -> Callable: + @functools.wraps(fn) + def wrapper(**kwargs): + doc_name = kwargs.get(param) if ptype != "create" else None + + if ptype != "create" and doc_name and not frappe.db.exists(doctype, doc_name): + frappe.throw( + _("{0} {1} not found").format(_(doctype), doc_name), + frappe.DoesNotExistError, + title=_("Not Found"), + ) + + allowed = frappe.has_permission(doctype=doctype, ptype=ptype, doc=doc_name) + if not allowed: + frappe.throw( + _("You do not have {0} permission on {1}").format(_(ptype), _(doctype)), + frappe.PermissionError, + title=_("Access Denied"), + ) + + return fn(**kwargs) + + return wrapper + + return decorator diff --git a/forms_pro/utils/test_form_generator.py b/forms_pro/utils/test_form_generator.py index db75420..c2592ac 100644 --- a/forms_pro/utils/test_form_generator.py +++ b/forms_pro/utils/test_form_generator.py @@ -4,13 +4,13 @@ import unittest import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase from forms_pro.utils.form_generator import FormGenerator from forms_pro.utils.teams import get_user_teams -class IntegrationTestFormGenerator(FrappeTestCase): +class IntegrationTestFormGenerator(IntegrationTestCase): def setUp(self): super().setUp() self.test_user = "test_forms_pro_user@example.com" diff --git a/forms_pro/utils/test_permissions.py b/forms_pro/utils/test_permissions.py new file mode 100644 index 0000000..73164bd --- /dev/null +++ b/forms_pro/utils/test_permissions.py @@ -0,0 +1,51 @@ +import frappe +from frappe.tests.utils import FrappeTestCase as IntegrationTestCase + +from forms_pro.tests import FORMS_PRO_TEST_USER +from forms_pro.tests.factories.form_factory import FormFactory +from forms_pro.utils.permissions import require_permission + + +class TestRequirePermission(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_allows_when_permission_granted(self) -> None: + @require_permission("Form", "read", param="form_id") + def fn(form_id: str) -> str: + return form_id + + self.assertEqual(fn(form_id=self.form.name), self.form.name) + + def test_raises_permission_error_when_denied(self) -> None: + @require_permission("Form", "write", param="form_id") + def fn(form_id: str) -> str: + return form_id + + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + fn(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_does_not_exist_when_doc_missing(self) -> None: + @require_permission("Form", "read", param="form_id") + def fn(form_id: str) -> str: + return form_id + + with self.assertRaises(frappe.DoesNotExistError) as ctx: + fn(form_id="NON_EXISTENT_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + def test_create_ptype_skips_existence_and_docname(self) -> None: + @require_permission("Form", "create") + def fn() -> str: + return "ok" + + self.assertEqual(fn(), "ok") + + def test_param_kwarg_routing(self) -> None: + @require_permission("Form", "read", param="my_id") + def fn(my_id: str) -> str: + return my_id + + self.assertEqual(fn(my_id=self.form.name), self.form.name) diff --git a/frontend/biome.json b/frontend/biome.json deleted file mode 100644 index 98ec0f2..0000000 --- a/frontend/biome.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "ignore": ["dist/**", "coverage/**", "node_modules/**", "../<app-name>/**"] - }, - "formatter": { - "enabled": true, - "indentStyle": "tab" - }, - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "asNeeded" - } - } -} diff --git a/frontend/e2e/fixtures/test-data.fixture.ts b/frontend/e2e/fixtures/test-data.fixture.ts index de1e9d8..b0d0a5b 100644 --- a/frontend/e2e/fixtures/test-data.fixture.ts +++ b/frontend/e2e/fixtures/test-data.fixture.ts @@ -22,6 +22,7 @@ async function fetchTeamId(apiContext: APIRequestContext): Promise<string> { } export const test = base.extend<TestDataFixtures>({ + // eslint-disable-next-line no-empty-pattern -- Playwright fixture requires destructuring syntax apiContext: async ({}, use) => { const ctx = await request.newContext({ baseURL: process.env.BASE_URL ?? "http://localhost:8001", diff --git a/frontend/e2e/helpers/form-builder.ts b/frontend/e2e/helpers/form-builder.ts index 7551581..d5af874 100644 --- a/frontend/e2e/helpers/form-builder.ts +++ b/frontend/e2e/helpers/form-builder.ts @@ -1,8 +1,234 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; export class FormBuilderPage { constructor(private page: Page) {} + // ---- Layout introspection helpers ---- + + fieldCard(label: string): Locator { + return this.page.locator( + `[data-form-builder-component="field-card"][data-field-label="${label}"]` + ); + } + + // Wait for the builder to finish hydrating with the form's fields (the + // form fetch is async after navigation; helps avoid races when tests load + // pre-built layouts via the REST API). + async waitForFields(labels: string[]) { + for (const label of labels) { + await this.fieldCard(label).waitFor({ state: "visible", timeout: 15000 }); + } + } + + async rowCount(): Promise<number> { + return this.page + .locator('[data-form-builder-component="form-row"]') + .count(); + } + + async columnCount(rowIdx: number): Promise<number> { + return this.page + .locator( + `[data-form-builder-component="cell-column"][data-row-index="${rowIdx}"]` + ) + .count(); + } + + async cellCount(rowIdx: number, colIdx: number): Promise<number> { + return this.page + .locator( + `[data-form-builder-component="field-card"][data-row-index="${rowIdx}"][data-col-index="${colIdx}"]` + ) + .count(); + } + + // ---- Drag mechanics ---- + // SortableJS + Playwright is fragile. Use a real mouse path with intermediate + // steps so SortableJS sees a continuous dragover sequence. + private async dragWithRealMouse( + sourceHandle: Locator, + targetCenter: { x: number; y: number }, + waypoints: { x: number; y: number }[] = [] + ) { + const handleBox = await sourceHandle.boundingBox(); + if (!handleBox) throw new Error("source handle not visible"); + + const start = { + x: handleBox.x + handleBox.width / 2, + y: handleBox.y + handleBox.height / 2, + }; + + await this.page.mouse.move(start.x, start.y); + await this.page.mouse.down(); + // Small initial nudge — SortableJS needs movement to begin drag + await this.page.mouse.move(start.x + 4, start.y + 4, { steps: 4 }); + + // Walk through caller-supplied waypoints (lets the path avoid crossing + // other sortable instances en route). Each leg uses many steps so + // SortableJS receives a continuous dragover/pointer-move sequence. + let prev = start; + for (const wp of waypoints) { + await this.page.mouse.move(wp.x, wp.y, { steps: 10 }); + prev = wp; + } + + // Approach the target with a midpoint then arrival + await this.page.mouse.move( + (prev.x + targetCenter.x) / 2, + (prev.y + targetCenter.y) / 2, + { steps: 10 } + ); + await this.page.mouse.move(targetCenter.x, targetCenter.y, { steps: 12 }); + + // Wiggle at target — forces a fresh dragover/pointer-move sequence on + // the final drop target so SortableJS commits the swap there, not + // somewhere we crossed earlier in the path. + await this.page.waitForTimeout(80); + await this.page.mouse.move(targetCenter.x + 2, targetCenter.y + 2, { + steps: 4, + }); + await this.page.mouse.move(targetCenter.x, targetCenter.y, { steps: 4 }); + await this.page.waitForTimeout(120); + await this.page.mouse.up(); + } + + private dragHandle(card: Locator): Locator { + return card.locator(".handle"); + } + + async dragFieldOntoCell( + sourceLabel: string, + targetLabel: string, + position: "above" | "below" + ) { + const source = this.fieldCard(sourceLabel); + const target = this.fieldCard(targetLabel); + + // Hover the target's group first so its handle/actions render (needed for + // the source handle to be visible — actions only show on hover/select). + await source.hover(); + const handle = this.dragHandle(source); + await handle.waitFor({ state: "visible" }); + + const sourceBox = await source.boundingBox(); + const box = await target.boundingBox(); + if (!sourceBox) throw new Error(`source card '${sourceLabel}' not visible`); + if (!box) throw new Error(`target card '${targetLabel}' not visible`); + + // Position cursor in upper / lower portion of target card + const yOffset = + position === "above" ? box.height * 0.25 : box.height * 0.75; + const targetCenter = { + x: box.x + box.width / 2, + y: box.y + yOffset, + }; + + // If the drag crosses rows, route through a waypoint that lives in the + // row drop zone between rows. This avoids accidentally swapping into + // another sortable instance (e.g. the neighbouring column in the source + // row) while passing through. Detect cross-row by comparing the cards' + // data-row-index attributes, which is robust to drop-zone size changes + // (a y-distance heuristic broke once RowDropZone shrank from h-6 to + // h-3, because adjacent rows became close enough that the vertical gap + // was smaller than a card's height). + const waypoints: { x: number; y: number }[] = []; + const sourceMidY = sourceBox.y + sourceBox.height / 2; + const sourceRow = Number( + (await source.getAttribute("data-row-index")) ?? 0 + ); + const targetRow = Number( + (await target.getAttribute("data-row-index")) ?? 0 + ); + const isCrossRow = sourceRow !== targetRow; + if (isCrossRow) { + // Pick the row-drop-zone whose centre lies in the vertical band + // between source and target — robust to duplicate data-at-row values + // and to changes in the zone's visual height. + const lo = Math.min(sourceMidY, targetCenter.y); + const hi = Math.max(sourceMidY, targetCenter.y); + let transitY = (sourceMidY + targetCenter.y) / 2; + const zones = await this.page + .locator('[data-form-builder-component="row-drop-zone"]') + .all(); + for (const z of zones) { + const zb = await z.boundingBox(); + if (!zb) continue; + const zMid = zb.y + zb.height / 2; + if (zMid > lo && zMid < hi) { + transitY = zMid; + break; + } + } + // Drop straight down (or up) along the source's x first so we leave + // the source's row through the row drop zone, then slide horizontally + // toward the target before approaching. + waypoints.push({ + x: sourceBox.x + sourceBox.width / 2, + y: transitY, + }); + waypoints.push({ x: targetCenter.x, y: transitY }); + } + + await this.dragWithRealMouse(handle, targetCenter, waypoints); + } + + // Drag a field onto a ColumnDropZone (gap between/around columns within a row). + // Targets the zone with [data-at-row=atRow][data-at-col=atCol]. + async dragFieldToColumnZone( + sourceLabel: string, + atRow: number, + atCol: number + ) { + const source = this.fieldCard(sourceLabel); + await source.hover(); + const handle = this.dragHandle(source); + await handle.waitFor({ state: "visible" }); + + const zone = this.page.locator( + `[data-form-builder-component="column-drop-zone"][data-at-row="${atRow}"][data-at-col="${atCol}"]` + ); + const box = await zone.boundingBox(); + if (!box) { + throw new Error(`column drop zone (${atRow},${atCol}) not visible`); + } + + await this.dragWithRealMouse(handle, { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }); + } + + // Click the eject button on the field's card (action bar). + // Action bar only mounts visible on hover/select — hover first. + async ejectField(label: string) { + const card = this.fieldCard(label); + await card.hover(); + const ejectBtn = card.locator( + '[data-form-builder-component="eject-button"]' + ); + await ejectBtn.waitFor({ state: "visible" }); + await ejectBtn.click(); + } + + // Drag a field onto a RowDropZone above row atRow. + async dragFieldToRowZone(sourceLabel: string, atRow: number) { + const source = this.fieldCard(sourceLabel); + await source.hover(); + const handle = this.dragHandle(source); + await handle.waitFor({ state: "visible" }); + + const zone = this.page.locator( + `[data-form-builder-component="row-drop-zone"][data-at-row="${atRow}"]` + ); + const box = await zone.boundingBox(); + if (!box) throw new Error(`row drop zone at row ${atRow} not visible`); + + await this.dragWithRealMouse(handle, { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }); + } + async goto( formId: string, options?: { title?: string; skipTitleFill?: boolean } @@ -40,12 +266,9 @@ export class FormBuilderPage { } async addField(fieldType: string) { - // Each card shows the field type name as visible text - const card = this.sidebar() - .getByText(fieldType, { exact: true }) - .locator(".."); - await card.hover(); - await card.getByRole("button").click(); + await this.sidebar() + .getByRole("button", { name: fieldType, exact: true }) + .click(); } // The canvas shows "Click on fields to add them…" when empty diff --git a/frontend/e2e/specs/add-fields-palette.spec.ts b/frontend/e2e/specs/add-fields-palette.spec.ts new file mode 100644 index 0000000..03c65e5 --- /dev/null +++ b/frontend/e2e/specs/add-fields-palette.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { FormBuilderPage } from "../helpers/form-builder"; + +test.describe("Add Fields palette", () => { + test("renders palette items as buttons without live input previews", async ({ + page, + createForm, + }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + const sidebar = page.locator( + '[data-form-builder-component="form-builder-sidebar"]' + ); + + // Palette items are buttons, named after the fieldtype + await expect( + sidebar.getByRole("button", { name: "Data", exact: true }) + ).toBeVisible(); + await expect( + sidebar.getByRole("button", { name: "Email", exact: true }) + ).toBeVisible(); + await expect( + sidebar.getByRole("button", { name: "Phone", exact: true }) + ).toBeVisible(); + + // No autofillable inputs inside palette buttons (regression: previously + // each palette card mounted a live FormControl, which triggered browser + // autofill on type=email / type=tel / type=password). + const emailBtn = sidebar.getByRole("button", { + name: "Email", + exact: true, + }); + await expect(emailBtn.locator("input")).toHaveCount(0); + + const phoneBtn = sidebar.getByRole("button", { + name: "Phone", + exact: true, + }); + await expect(phoneBtn.locator("input")).toHaveCount(0); + }); + + test("search filters palette items", async ({ page, createForm }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + const sidebar = page.locator( + '[data-form-builder-component="form-builder-sidebar"]' + ); + + await expect( + sidebar.getByRole("button", { name: "Data", exact: true }) + ).toBeVisible(); + await expect( + sidebar.getByRole("button", { name: "Phone", exact: true }) + ).toBeVisible(); + + await sidebar.getByPlaceholder("Search Fields").fill("date"); + + // "Date", "Date Time", "Date Range" remain (case-insensitive substring) + await expect( + sidebar.getByRole("button", { name: "Date", exact: true }) + ).toBeVisible(); + // Unrelated types are filtered out + await expect( + sidebar.getByRole("button", { name: "Phone", exact: true }) + ).toHaveCount(0); + await expect( + sidebar.getByRole("button", { name: "Data", exact: true }) + ).toHaveCount(0); + }); + + test("clicking a palette item adds the field to the canvas", async ({ + page, + createForm, + }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + await expect(builder.canvasEmptyState()).toBeVisible(); + await builder.addField("Email"); + await expect(builder.canvasEmptyState()).not.toBeVisible(); + }); +}); diff --git a/frontend/e2e/specs/form-layout.spec.ts b/frontend/e2e/specs/form-layout.spec.ts new file mode 100644 index 0000000..ee324df --- /dev/null +++ b/frontend/e2e/specs/form-layout.spec.ts @@ -0,0 +1,326 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { FormBuilderPage } from "../helpers/form-builder"; +import { SubmissionPage } from "../helpers/submission"; + +// Helper: set a form's field list via REST so the builder loads with a known +// row/column/cell layout. Mirrors the pattern used in heading-field.spec.ts. +async function setFormFields( + apiContext: import("@playwright/test").APIRequestContext, + formId: string, + fields: Array<{ + label: string; + fieldtype?: string; + row_index: number; + column_index?: number; + cell_index?: number; + }> +) { + await apiContext.put(`/api/resource/Form/${formId}`, { + data: { + fields: fields.map((f, i) => ({ + idx: i + 1, + fieldtype: f.fieldtype ?? "Data", + label: f.label, + fieldname: f.label.toLowerCase(), + reqd: 0, + row_index: f.row_index, + column_index: f.column_index ?? 0, + cell_index: f.cell_index ?? 0, + })), + }, + }); +} + +test.describe("Multi-column form layout", () => { + test("cell stacks into existing column on drag", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0 }, + { label: "B", row_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + // Sanity: starting layout is 2 rows, 1 column each, 1 cell each + await builder.waitForFields(["A", "B"]); + expect(await builder.rowCount()).toBe(2); + + await builder.dragFieldOntoCell("B", "A", "above"); + + // After drag: 1 row, 1 column, 2 cells stacked in (row 0, col 0) + await expect.poll(() => builder.rowCount()).toBe(1); + await expect.poll(() => builder.columnCount(0)).toBe(1); + await expect.poll(() => builder.cellCount(0, 0)).toBe(2); + }); + + test("column drop zone creates new column", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0 }, + { label: "B", row_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + + // Drop B into the column drop zone right of A (row 0, col 1) + await builder.dragFieldToColumnZone("B", 0, 1); + + await expect.poll(() => builder.rowCount()).toBe(1); + await expect.poll(() => builder.columnCount(0)).toBe(2); + await expect.poll(() => builder.cellCount(0, 0)).toBe(1); + await expect.poll(() => builder.cellCount(0, 1)).toBe(1); + }); + + test("row drop zone creates new row", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2-column row of A, B in row 0 + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0 }, + { label: "B", row_index: 0, column_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + expect(await builder.columnCount(0)).toBe(2); + + // Drop B above current row — B becomes row 0, A pushed to row 1 + await builder.dragFieldToRowZone("B", 0); + + await expect.poll(() => builder.rowCount()).toBe(2); + await expect.poll(() => builder.columnCount(0)).toBe(1); + await expect.poll(() => builder.columnCount(1)).toBe(1); + }); + + test("eject moves cell to new row", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2-cell column: A on top (cell 0), B below (cell 1) — same row, same col + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0, cell_index: 0 }, + { label: "B", row_index: 0, column_index: 0, cell_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + expect(await builder.cellCount(0, 0)).toBe(2); + + // Eject A — A moves to its own row, B stays put + await builder.ejectField("A"); + + await expect.poll(() => builder.rowCount()).toBe(2); + // No multi-cell column should remain + const rows = await builder.rowCount(); + for (let r = 0; r < rows; r++) { + const cols = await builder.columnCount(r); + for (let c = 0; c < cols; c++) { + expect(await builder.cellCount(r, c)).toBe(1); + } + } + }); + + test("cross-row drag collapses source column", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2 rows x 2 cols: A, B / C, D + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0 }, + { label: "B", row_index: 0, column_index: 1 }, + { label: "C", row_index: 1, column_index: 0 }, + { label: "D", row_index: 1, column_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B", "C", "D"]); + expect(await builder.columnCount(0)).toBe(2); + expect(await builder.columnCount(1)).toBe(2); + + // Stack B onto C — moves B into row 1, col 0 (cell 0 above C) + await builder.dragFieldOntoCell("B", "C", "above"); + + // Row 0 collapses to 1 column (only A); row 1 still has 2 columns + await expect.poll(() => builder.rowCount()).toBe(2); + await expect.poll(() => builder.columnCount(0)).toBe(1); + await expect.poll(() => builder.columnCount(1)).toBe(2); + await expect.poll(() => builder.cellCount(1, 0)).toBe(2); + }); + + test("within-column reorder renumbers cell_index", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2-cell column: A on top, B on bottom + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0, cell_index: 0 }, + { label: "B", row_index: 0, column_index: 0, cell_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + + // Drag B above A — DOM order should flip to B, A + await builder.dragFieldOntoCell("B", "A", "above"); + + const labelsInColumn = () => + page + .locator( + '[data-form-builder-component="cell-column"][data-row-index="0"][data-col-index="0"] [data-form-builder-component="field-card"]' + ) + .evaluateAll((els) => + els.map((e) => (e as HTMLElement).getAttribute("data-field-label")) + ); + + await expect.poll(labelsInColumn).toEqual(["B", "A"]); + }); + + test("mobile viewport stacks columns vertically", async ({ + browser, + createPublishedForm, + apiContext, + }) => { + const { formId, route } = await createPublishedForm(); + // 2-column row of A, B (use Data fieldtype so they render as inputs) + await apiContext.put(`/api/resource/Form/${formId}`, { + data: { + fields: [ + { + idx: 1, + fieldtype: "Data", + label: "A", + fieldname: "a", + row_index: 0, + column_index: 0, + cell_index: 0, + }, + { + idx: 2, + fieldtype: "Data", + label: "B", + fieldname: "b", + row_index: 0, + column_index: 1, + cell_index: 0, + }, + ], + }, + }); + + // Mobile viewport + const guestCtx = await browser.newContext({ + viewport: { width: 375, height: 800 }, + }); + const guestPage = await guestCtx.newPage(); + const submission = new SubmissionPage(guestPage); + await submission.goto(route); + await guestPage.waitForLoadState("networkidle"); + + const row = guestPage.locator('[data-form-renderer-component="form-row"]'); + await expect(row).toBeVisible({ timeout: 15000 }); + + const flexDirection = await row.evaluate( + (el) => getComputedStyle(el as HTMLElement).flexDirection + ); + expect(flexDirection).toBe("column"); + + await guestCtx.close(); + }); + + test("hidden field unmounts, no empty column", async ({ + browser, + createPublishedForm, + apiContext, + }) => { + const { formId, route } = await createPublishedForm(); + + // 2-column row: A (col 0), B (col 1). A carries a conditional rule that + // hides B whenever A is empty (it always is on first render), exercising + // the "hide a column entirely when its only field is hidden" path. + const conditionalLogic = JSON.stringify({ + target_field: "b", + conditions: [{ fieldname: "a", operator: "Is Empty", value: "" }], + action: "Hide Field", + }); + + await apiContext.put(`/api/resource/Form/${formId}`, { + data: { + fields: [ + { + idx: 1, + fieldtype: "Data", + label: "A", + fieldname: "a", + row_index: 0, + column_index: 0, + cell_index: 0, + conditional_logic: conditionalLogic, + }, + { + idx: 2, + fieldtype: "Data", + label: "B", + fieldname: "b", + row_index: 0, + column_index: 1, + cell_index: 0, + }, + ], + }, + }); + + const guestCtx = await browser.newContext(); + const guestPage = await guestCtx.newPage(); + const submission = new SubmissionPage(guestPage); + await submission.goto(route); + + // Wait for the Submit button — confirms the renderer mounted past + // initial load (the form layout is reactive to the API fetch). + await expect(guestPage.getByRole("button", { name: "Submit" })).toBeVisible( + { timeout: 10000 } + ); + + // Row should render with exactly one column (the one holding A); the + // column wrapping B must be unmounted (v-if), not just emptied. + const row = guestPage.locator('[data-form-renderer-component="form-row"]'); + await expect(row).toHaveCount(1); + const columns = row.locator('[data-form-renderer-component="form-column"]'); + await expect(columns).toHaveCount(1); + + // Only A's input should render — B is unmounted by the conditional rule + // (frappe-ui's FormControl doesn't expose `name`, so count text inputs + // inside the form column instead). + await expect(columns.locator("input[type='text']")).toHaveCount(1); + + await guestCtx.close(); + }); +}); diff --git a/frontend/e2e/specs/rating-field.spec.ts b/frontend/e2e/specs/rating-field.spec.ts new file mode 100644 index 0000000..9f94d8f --- /dev/null +++ b/frontend/e2e/specs/rating-field.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { SubmissionPage } from "../helpers/submission"; + +test.describe("Rating field", () => { + test("retains the value the user clicked (no clamp to 1.0)", async ({ + browser, + createPublishedForm, + apiContext, + }) => { + const { formId, route } = await createPublishedForm(); + + // Add a Rating field to the published form via REST + const formRes = await apiContext.get(`/api/resource/Form/${formId}`); + const { data: formData } = await formRes.json(); + const linkedDoctype: string = formData.linked_doctype; + const ratingFieldname = "satisfaction"; + + await apiContext.put(`/api/resource/Form/${formId}`, { + data: { + fields: [ + ...(formData.fields ?? []), + { + fieldtype: "Rating", + label: "Satisfaction", + fieldname: ratingFieldname, + reqd: 0, + }, + ], + }, + }); + + // Guest fills + submits the form + const guestCtx = await browser.newContext(); + const guestPage = await guestCtx.newPage(); + const submissionPage = new SubmissionPage(guestPage); + await submissionPage.goto(route); + + await expect(guestPage.getByRole("button", { name: "Submit" })).toBeVisible( + { timeout: 10000 } + ); + + // Click the 3rd star (1-indexed → nth(2)). feather-icons stamps the class. + const stars = guestPage.locator("svg.feather-star"); + await expect(stars).toHaveCount(5); + await stars.nth(2).click(); + + await submissionPage.submit(); + await expect(submissionPage.successMessage()).toBeVisible({ + timeout: 10000, + }); + + await guestCtx.close(); + + // Fetch the submission record via REST and assert the stored value. + // Frappe stores Rating as a 0..1 fraction → 3/5 == 0.6. + const listRes = await apiContext.get( + `/api/resource/${encodeURIComponent(linkedDoctype)}`, + { + params: { + filters: JSON.stringify([["fp_linked_form", "=", formId]]), + fields: JSON.stringify(["name"]), + limit_page_length: 1, + }, + } + ); + const { data: list } = await listRes.json(); + expect(list).toHaveLength(1); + const submissionName = list[0].name; + + const getRes = await apiContext.get( + `/api/resource/${encodeURIComponent(linkedDoctype)}/${encodeURIComponent( + submissionName + )}` + ); + const { data: submission } = await getRes.json(); + expect(submission[ratingFieldname]).toBeCloseTo(0.6, 5); + }); +}); diff --git a/frontend/e2e/specs/route-perms.spec.ts b/frontend/e2e/specs/route-perms.spec.ts new file mode 100644 index 0000000..9a59717 --- /dev/null +++ b/frontend/e2e/specs/route-perms.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "../fixtures/test-data.fixture"; + +test.describe("Route-level permissions", () => { + test("bogus form id renders Not Found and surfaces 404", async ({ page }) => { + const responsePromise = page.waitForResponse( + (res) => + res + .url() + .includes("/api/method/forms_pro.api.form.get_form_for_view") && + res.status() === 404, + { timeout: 15000 } + ); + + await page.goto("/forms/manage/MISSING-FORM-XYZ-E2E"); + + const response = await responsePromise; + expect(response.status()).toBe(404); + + await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible( + { timeout: 10000 } + ); + }); + + test("owner navigates to edit-form and the builder renders", async ({ + page, + createForm, + }) => { + const formId = await createForm(); + + const responsePromise = page.waitForResponse( + (res) => + res + .url() + .includes("/api/method/forms_pro.api.form.get_form_for_edit") && + res.status() === 200, + { timeout: 15000 } + ); + + await page.goto(`/forms/edit-form/${formId}`); + + const response = await responsePromise; + expect(response.status()).toBe(200); + + // RouteError must not render for an authorized owner. + await expect( + page.getByRole("heading", { name: /access denied|not found/i }) + ).toHaveCount(0); + }); + + // Deferred: read-only viewer hitting /edit-form/:id should see "Access Denied" + // plus a 403 in the network. Reliable setup needs an admin-owned form shared + // read-only with the test user, which requires a second authenticated API + // context (admin login) that the current fixture does not provide. Add when + // we wire an admin fixture into e2e/global-setup. +}); diff --git a/frontend/oxlint.json b/frontend/oxlint.json new file mode 100644 index 0000000..6b53c21 --- /dev/null +++ b/frontend/oxlint.json @@ -0,0 +1,13 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "rules": { + "no-unused-vars": "warn", + "no-console": "warn", + "eqeqeq": "error" + }, + "ignorePatterns": [ + "dist/**", + "coverage/**", + "node_modules/**" + ] +} diff --git a/frontend/package.json b/frontend/package.json index 6721ea6..300f46c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build --base=/assets/forms_pro/frontend/ && yarn copy-html-entry", "preview": "vite preview", - "lint": "biome check --write .", + "lint": "oxlint .", "typecheck": "./typecheck.sh", "copy-html-entry": "cp ../forms_pro/public/frontend/index.html ../forms_pro/www/forms.html", "test:e2e": "playwright test", @@ -15,34 +15,35 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { - "@lottiefiles/dotlottie-vue": "^0.11.11", - "@vueuse/core": "^13.9.0", + "@lottiefiles/dotlottie-vue": "^0.11.12", + "@lucide/vue": "^1.11.0", + "@vueuse/core": "^14.3.0", "dayjs": "^1.11.20", "feather-icons": "^4.29.2", "frappe-ui": "^0.1.272", - "lucide-vue-next": "^0.575.0", - "pinia": "^3.0.3", + "pinia": "^3.0.4", "socket.io-client": "^4.8.3", + "torph": "^0.0.9", "vue": "^3.5.33", "vue-advanced-cropper": "^2.6.2", - "vue-router": "^4.5.0", + "vue-router": "^4.6.4", "vue-sonner": "^2.0.9", "vuedraggable": "^4.1.0", "zod": "^4.3.6" }, "devDependencies": { - "@biomejs/biome": "1.9.4", - "@playwright/test": "^1.59.1", - "@types/node": "^25.6.0", + "@playwright/test": "^1.60.0", + "@types/node": "^25.7.0", "@vitejs/plugin-vue": "^6.0.6", "@vitest/coverage-v8": "^4.1.4", - "autoprefixer": "^10.4.2", + "autoprefixer": "^10.5.0", "jsdom": "^29.0.2", - "postcss": "^8.5.10", + "oxlint": "^1.64.0", + "postcss": "^8.5.14", "tailwindcss": "^3.4.15", "typescript": "^5.9.3", "vite": "^8.0.8", "vitest": "^4.1.4", - "vue-tsc": "^3.2.4" + "vue-tsc": "^3.2.8" } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 511fbf0..ad6f7dd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,7 @@ import "vue-sonner/style.css"; import { Toaster } from "vue-sonner"; import GlobalDialog from "@/components/ui/GlobalDialog.vue"; +import RouteProgress from "@/components/RouteProgress.vue"; import { useUser } from "@/stores/user"; const userStore = useUser(); @@ -12,6 +13,7 @@ userStore.initialize(); <div> <router-view /> </div> + <RouteProgress /> <Toaster theme="system" /> <GlobalDialog /> </template> diff --git a/frontend/src/components/FormBuilderContent.vue b/frontend/src/components/FormBuilderContent.vue index 5b77849..d7b2e71 100644 --- a/frontend/src/components/FormBuilderContent.vue +++ b/frontend/src/components/FormBuilderContent.vue @@ -1,28 +1,87 @@ <script setup lang="ts"> -import draggableComponent from "vuedraggable"; +import { computed, ref } from "vue"; import { LoadingIndicator, TextEditor } from "frappe-ui"; -import { useEditForm } from "@/stores/editForm"; -import { FormField } from "@/types/formfield"; -import { ref } from "vue"; -import { onClickOutside } from "@vueuse/core"; +import { onClickOutside, useEventListener } from "@vueuse/core"; +import draggableComponent from "vuedraggable"; -import FieldRenderer from "@/components/builder/FieldRenderer.vue"; -import FieldActions from "@/components/builder/FieldActions.vue"; +import { useEditForm } from "@/stores/editForm"; +import { useGroupedRows } from "@/composables/useGroupedRows"; +import type { FormField } from "@/types/formfield"; +import FieldCard from "@/components/builder/FieldCard.vue"; +import RowDropZone from "@/components/builder/RowDropZone.vue"; +import ColumnDropZone from "@/components/builder/ColumnDropZone.vue"; const editFormStore = useEditForm(); -// Ref for the entire FormBuilderContent component const fieldContentRef = ref<HTMLElement | null>(null); const isDraggingField = ref(false); +function resetDragState() { + isDraggingField.value = false; +} + +useEventListener(document, "pointerup", () => { + if (isDraggingField.value) resetDragState(); +}); +useEventListener(document, "dragend", () => { + if (isDraggingField.value) resetDragState(); +}); + +const groupedRows = useGroupedRows(computed(() => editFormStore.fields)); + +function fieldKey(field: FormField): string { + return `${field.row_index ?? 0}-${field.column_index ?? 0}-${field.cell_index ?? 0}`; +} + +function rowIndexOf(row: FormField[][], rIdx: number): number { + return row[0]?.[0]?.row_index ?? rIdx; +} + +function colIndexOf(col: FormField[], cIdx: number): number { + return col[0]?.column_index ?? cIdx; +} + +function onCellChange(evt: any, rowIndex: number, colIndex: number) { + if (evt.moved) { + // Reorder cells within same column: renumber cell_index by new position + const { element, newIndex } = evt.moved; + const cells: FormField[] = editFormStore.fields + .filter( + (f: FormField) => + (f.row_index ?? 0) === rowIndex && (f.column_index ?? 0) === colIndex + ) + .sort((a: FormField, b: FormField) => (a.cell_index ?? 0) - (b.cell_index ?? 0)); + const oldIdx = cells.indexOf(element); + if (oldIdx === -1) return; + cells.splice(oldIdx, 1); + cells.splice(newIndex, 0, element); + cells.forEach((f: FormField, i: number) => { + f.cell_index = i; + }); + } else if (evt.added) { + // Field dropped into this column from elsewhere — stack into column at cell index + editFormStore.insertCell(evt.added.element, rowIndex, colIndex, evt.added.newIndex); + resetDragState(); + } + // evt.removed: no-op — target column's evt.added owns the move +} + +function onColumnZoneDrop(field: FormField, atRow: number, atCol: number) { + editFormStore.moveField(field, atRow, atCol); + resetDragState(); +} + +function onRowZoneDrop(field: FormField, atRow: number) { + editFormStore.insertNewRow(field, atRow); + resetDragState(); +} + // Function to check if an element is a dropdown/popover (including portals) const isDropdownOrPopover = (element: Element | null): boolean => { if (!element) return false; - // Walk up the DOM tree to check for dropdown indicators let current: Element | null = element; while (current && current !== document.body) { - // Check for Headless UI patterns if ( current.hasAttribute("role") && (current.getAttribute("role") === "listbox" || @@ -32,12 +91,10 @@ const isDropdownOrPopover = (element: Element | null): boolean => { return true; } - // Check for Headless UI data attributes if (current.hasAttribute("data-headlessui-state") || current.id?.includes("headlessui")) { return true; } - // Check for Radix UI patterns if ( current.hasAttribute("data-radix-popper-content-wrapper") || current.id?.startsWith("radix") || @@ -46,7 +103,6 @@ const isDropdownOrPopover = (element: Element | null): boolean => { return true; } - // Check for common dropdown classes const classList = current.classList; if ( classList.contains("dropdown-menu") || @@ -63,9 +119,14 @@ const isDropdownOrPopover = (element: Element | null): boolean => { return false; }; -// Set up outside click detection for the entire FormBuilderContent component +useEventListener("keydown", (event: KeyboardEvent) => { + if (event.metaKey && event.key === "s") { + event.preventDefault(); + editFormStore.save(); + } +}); + onClickOutside(fieldContentRef, (event) => { - // Check if the click is on any other form builder components const target = event.target as Element; const isFormBuilderComponent = target.closest("[data-form-builder-component]") || @@ -73,12 +134,8 @@ onClickOutside(fieldContentRef, (event) => { target.closest(".form-builder-sidebar") || target.closest(".form-builder-header"); - // Check if the click is on a dropdown menu (which may be rendered in a portal) - // This handles Headless UI, Radix UI, and other common dropdown patterns const isDropdownElement = isDropdownOrPopover(target); - // Also check if there are any visible/open dropdowns in the DOM - // This catches dropdowns that might be open but the click target isn't directly on them const hasOpenDropdown = !!( document.querySelector('[role="listbox"]:not([hidden]):not([style*="display: none"])') || document.querySelector('[role="combobox"][aria-expanded="true"]') || @@ -86,8 +143,6 @@ onClickOutside(fieldContentRef, (event) => { document.querySelector('[aria-expanded="true"][role="combobox"]') ); - // Check if the active element (focused element) is within the sidebar - // This helps catch cases where a dropdown is open and the user is interacting with it const activeElement = document.activeElement; const isActiveElementInSidebar = activeElement ? !!( @@ -97,8 +152,6 @@ onClickOutside(fieldContentRef, (event) => { ) : false; - // Only deselect if NOT clicking on other form builder components or dropdowns - // Also don't deselect if there's an open dropdown or if the active element is in the sidebar if ( !isFormBuilderComponent && !isDropdownElement && @@ -109,6 +162,7 @@ onClickOutside(fieldContentRef, (event) => { } }); </script> + <template> <div v-if="editFormStore.isLoading"> <LoadingIndicator /> @@ -145,36 +199,71 @@ onClickOutside(fieldContentRef, (event) => { <p class="text-base">Click on fields to add them to the form.</p> </div> </div> - <div> - <draggableComponent - :list="editFormStore.fields" - item-key="idx" - tag="div" - handle=".handle" - @start="isDraggingField = true" - @end="isDraggingField = false" - > - <template #item="{ element }"> - <div - @click="editFormStore.selectField(element)" - class="my-3 relative transition-colors group" + <div class="flex flex-col"> + <template v-for="(row, rIdx) in groupedRows" :key="rowIndexOf(row, rIdx)"> + <RowDropZone + :atRow="rowIndexOf(row, rIdx)" + :isDragging="isDraggingField" + @drop="onRowZoneDrop" + /> + <div + class="flex flex-row items-stretch" + data-form-builder-component="form-row" + :data-row-index="rowIndexOf(row, rIdx)" + > + <template + v-for="(col, cIdx) in row" + :key="`${rowIndexOf(row, rIdx)}-${colIndexOf(col, cIdx)}`" > - <FieldActions - :isSelected="editFormStore.selectedField === element" - :isDraggingAnyField="isDraggingField" - @remove="editFormStore.removeField(element)" + <ColumnDropZone + :atRow="rowIndexOf(row, rIdx)" + :atCol="colIndexOf(col, cIdx)" + :isDragging="isDraggingField" + @drop="onColumnZoneDrop" /> - <FieldRenderer - :field="element" - @update:field=" - (updatedField: FormField) => - editFormStore.updateField(element, updatedField) + <draggableComponent + :list="[...col]" + :group="{ name: 'fields' }" + :item-key="fieldKey" + :animation="150" + handle=".handle" + ghost-class="opacity-50" + tag="div" + :force-fallback="true" + data-form-builder-component="cell-column" + :data-row-index="rowIndexOf(row, rIdx)" + :data-col-index="colIndexOf(col, cIdx)" + class="flex flex-col gap-4 flex-1 min-w-0" + @change=" + (evt: any) => + onCellChange( + evt, + rowIndexOf(row, rIdx), + colIndexOf(col, cIdx) + ) " - :inEditMode="true" - /> - </div> - </template> - </draggableComponent> + @start="isDraggingField = true" + @end="isDraggingField = false" + > + <template #item="{ element: field }"> + <FieldCard :field="field" :isDraggingAnyField="isDraggingField" /> + </template> + </draggableComponent> + </template> + <ColumnDropZone + :atRow="rowIndexOf(row, rIdx)" + :atCol="row.length" + :isDragging="isDraggingField" + @drop="onColumnZoneDrop" + /> + </div> + <RowDropZone + v-show="rIdx === groupedRows.length - 1" + :atRow="rowIndexOf(row, rIdx) + 1" + :isDragging="isDraggingField" + @drop="onRowZoneDrop" + /> + </template> </div> </div> </template> diff --git a/frontend/src/components/FormBuilderHeader.vue b/frontend/src/components/FormBuilderHeader.vue index 512d0a9..931c664 100644 --- a/frontend/src/components/FormBuilderHeader.vue +++ b/frontend/src/components/FormBuilderHeader.vue @@ -1,14 +1,97 @@ <script setup lang="ts"> -import { Badge, Popover, Tooltip } from "frappe-ui"; -import { ChevronDown, CloudCheck, ExternalLink, CloudOff } from "lucide-vue-next"; -import { Button } from "frappe-ui"; +import { Badge, Button, Popover, Tooltip, type ButtonProps } from "frappe-ui"; +import { TextMorph } from "torph/vue"; +import { ChevronDown, CloudCheck, ExternalLink, CloudOff } from "@lucide/vue"; import { useEditForm } from "@/stores/editForm"; import { useRouter } from "vue-router"; +import { computed, nextTick, onMounted, ref, watch } from "vue"; import Logo from "@/assets/Logo.vue"; const router = useRouter(); const editFormStore = useEditForm(); +const SUBMISSION_ROUTE_NAME = "Form Submission Page"; + +type ActionConfig = { + label: string; + iconLeft: string; + variant: ButtonProps["variant"]; + theme: ButtonProps["theme"]; + handler: () => unknown; +}; + +function computeConfig(): ActionConfig { + if (editFormStore.isUnsaved && editFormStore.isPublished) + return { + label: "Save and publish", + iconLeft: "globe", + variant: "solid", + theme: "gray", + handler: editFormStore.saveAndPublish, + }; + if (editFormStore.isUnsaved) + return { + label: "Save", + iconLeft: "", + variant: "solid", + theme: "gray", + handler: editFormStore.save, + }; + if (editFormStore.isPublished) + return { + label: "Unpublish", + iconLeft: "", + variant: "subtle", + theme: "red", + handler: editFormStore.togglePublish, + }; + return { + label: "Publish", + iconLeft: "globe", + variant: "solid", + theme: "gray", + handler: editFormStore.togglePublish, + }; +} + +const frozenConfig = ref<ActionConfig | null>(null); +const buttonConfig = computed<ActionConfig>(() => frozenConfig.value ?? computeConfig()); + +watch( + () => editFormStore.formResource, + (r) => { + if (!r) frozenConfig.value = null; + } +); + +// torph@0.0.9 TextMorph skips render when mounted with static text: +// MorphController.attach() only calls update() if lastText is set, and the +// text-prop watcher has no `immediate: true`. Mount with empty string, then +// assign post-mount so the watcher fires and populates the DOM. +const morphLabel = ref(""); +onMounted(() => { + morphLabel.value = buttonConfig.value.label; +}); +watch( + () => buttonConfig.value.label, + (l) => { + morphLabel.value = l; + } +); + +async function onAction() { + if (frozenConfig.value) return; + frozenConfig.value = computeConfig(); + try { + await frozenConfig.value.handler(); + await nextTick(); + } catch { + // save() rejects on duplicate fieldnames / no-changes; togglePublish surfaces errors via toast + } finally { + frozenConfig.value = null; + } +} + const openFormSubmissionPage = () => { const route = editFormStore.originalFormData?.route; if (!route) return; @@ -20,10 +103,10 @@ const openFormSubmissionPage = () => { // Solution: Get the route definition and construct the path manually, // then use router.resolve() with the path string (which doesn't get encoded). // This way, if the path changes in router.ts, this code still works. - const routeRecord = router.getRoutes().find((r) => r.name === "Form Submission Page"); + const routeRecord = router.getRoutes().find((r) => r.name === SUBMISSION_ROUTE_NAME); if (!routeRecord) return; - const path = routeRecord.path.replace(":route(.*)", route); + const path = routeRecord.path.replace(/:\w+\(.*?\)/, route); const routeData = router.resolve(path); window.open(routeData.href, "_blank"); @@ -31,7 +114,7 @@ const openFormSubmissionPage = () => { </script> <template> <header - class="form-builder-header flex justify-between items-center py-2 px-4 border-b h-[3rem] transition-all duration-300" + class="form-builder-header flex justify-between items-center py-2 px-4 border-b h-[3rem]" data-form-builder-component="form-builder-header" > <Popover> @@ -59,7 +142,7 @@ const openFormSubmissionPage = () => { </div> </template> </Popover> - <div class="flex items-center gap-2"> + <div class="flex items-center gap-2 m-auto"> <Badge v-if="editFormStore.isUnsaved" variant="subtle" @@ -74,11 +157,7 @@ const openFormSubmissionPage = () => { > <CloudCheck class="w-4 h-4 text-gray-500" /> </Tooltip> - <Tooltip - v-else-if="!editFormStore.isPublished" - text="Form is not published" - placement="bottom" - > + <Tooltip v-else text="Form is not published" placement="bottom"> <CloudOff class="w-4 h-4 text-gray-500" /> </Tooltip> <h3 class="text-base font-medium text-gray-600 text-center"> @@ -96,32 +175,16 @@ const openFormSubmissionPage = () => { </div> </div> <div class="flex items-center gap-2"> - <div v-if="editFormStore.isUnsaved"> - <Button - v-if="editFormStore.isPublished" - label="Save and publish" - icon-left="globe" - variant="solid" - @click="editFormStore.saveAndPublish" - :loading="editFormStore.formResource?.loading" - /> - <Button - v-else - label="Save" - variant="solid" - @click="editFormStore.save" - :loading="editFormStore.formResource?.loading" - /> - </div> <Button - v-else - :label="editFormStore.isPublished ? 'Unpublish' : 'Publish'" - :icon-left="editFormStore.isPublished ? '' : 'globe'" - :variant="editFormStore.isPublished ? 'subtle' : 'solid'" - :theme="editFormStore.isPublished ? 'red' : 'gray'" - :loading="editFormStore.formResource?.loading" - @click="editFormStore.togglePublish" - /> + :aria-label="buttonConfig.label" + :icon-left="buttonConfig.iconLeft" + :variant="buttonConfig.variant" + :theme="buttonConfig.theme" + :loading="editFormStore.isSaving || editFormStore.isLoading" + @click="onAction" + > + <TextMorph :text="morphLabel" /> + </Button> </div> </header> </template> diff --git a/frontend/src/components/FormBuilderSidebar.vue b/frontend/src/components/FormBuilderSidebar.vue index 85d53a2..d0afd6e 100644 --- a/frontend/src/components/FormBuilderSidebar.vue +++ b/frontend/src/components/FormBuilderSidebar.vue @@ -1,56 +1,108 @@ <script setup lang="ts"> -import { Settings, Plus, StretchHorizontal } from "lucide-vue-next"; -import { ref } from "vue"; +import { Settings, Plus, StretchHorizontal } from "@lucide/vue"; +import { computed, ref } from "vue"; import { Tooltip } from "frappe-ui"; import AddFieldsSection from "@/components/builder/sidebar/AddFieldsSection.vue"; import SettingsSection from "@/components/builder/sidebar/SettingsSection.vue"; import DocTypeFieldsSection from "@/components/builder/sidebar/DoctypeFieldsSection.vue"; -const sidebarSections = ref([ +const sidebarSections = [ { - id: 0, + id: "settings", label: "Settings", icon: Settings, section: SettingsSection, }, { - id: 1, + id: "add-fields", label: "Add Fields", icon: Plus, section: AddFieldsSection, }, { - id: 2, + id: "doctype-fields", label: "DocType Fields", icon: StretchHorizontal, section: DocTypeFieldsSection, }, -]); +]; -const activeSection = ref(sidebarSections.value[1]); +const activeSection = ref( + sidebarSections.find((s) => s.id === "add-fields") ?? sidebarSections[0] +); + +const tabId = (id: string) => `form-builder-tab-${id}`; +const panelId = (id: string) => `form-builder-panel-${id}`; +const activeTabId = computed(() => tabId(activeSection.value.id)); +const activePanelId = computed(() => panelId(activeSection.value.id)); </script> <template> <div - class="form-builder-sidebar bg-primary h-[calc(100vh-3rem)] w-72 border-r sticky top-0 overflow-y-auto flex" + class="form-builder-sidebar bg-surface-white h-[calc(100dvh-3rem)] w-72 border-r border-outline-gray-1 sticky top-0 overflow-y-auto flex" data-form-builder-component="form-builder-sidebar" > - <div class="h-full bg-inherit flex flex-col gap-2 p-2 border-r"> + <div + role="tablist" + aria-label="Form builder sections" + aria-orientation="vertical" + class="h-full bg-inherit flex flex-col gap-2 p-2 border-r border-outline-gray-1" + > <Tooltip v-for="section in sidebarSections" :key="section.id" :text="section.label" placement="right" > - <Button - size="md" - @click="activeSection = section" - :variant="activeSection === section ? 'subtle' : 'ghost'" - :icon="section.icon" - /> + <div class="relative"> + <span + v-if="activeSection.id === section.id" + aria-hidden="true" + class="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-full bg-surface-gray-7" + /> + <Button + size="md" + role="tab" + :id="tabId(section.id)" + :aria-label="section.label" + :aria-selected="activeSection.id === section.id" + :aria-controls="panelId(section.id)" + @click="activeSection = section" + :variant="activeSection.id === section.id ? 'subtle' : 'ghost'" + :icon="section.icon" + /> + </div> </Tooltip> </div> - <div class="flex flex-col gap-4 w-full p-4 overflow-x-hidden"> - <component :is="activeSection.section" /> + <div + role="tabpanel" + :id="activePanelId" + :aria-labelledby="activeTabId" + tabindex="0" + class="flex flex-col gap-4 w-full p-4 overflow-x-hidden focus:outline-none" + > + <Transition name="section-fade" mode="out-in"> + <component :is="activeSection.section" :key="activeSection.id" /> + </Transition> </div> </div> </template> + +<style scoped> +.section-fade-enter-active { + transition: opacity 120ms ease-out; +} +.section-fade-leave-active { + transition: opacity 120ms ease-in; +} +.section-fade-enter-from, +.section-fade-leave-to { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .section-fade-enter-active, + .section-fade-leave-active { + transition: none; + } +} +</style> diff --git a/frontend/src/components/RouteError.vue b/frontend/src/components/RouteError.vue new file mode 100644 index 0000000..e3efa65 --- /dev/null +++ b/frontend/src/components/RouteError.vue @@ -0,0 +1,48 @@ +<script setup lang="ts"> +import { computed } from "vue"; +import { Lock, FileQuestion, AlertTriangle } from "@lucide/vue"; +import { Button } from "frappe-ui"; +import { useRouter } from "vue-router"; + +const props = defineProps<{ + excType?: string; + httpStatus?: number; + messages?: string[]; +}>(); + +const router = useRouter(); + +const META: Record<string, { title: string; icon: unknown }> = { + PermissionError: { title: "Access Denied", icon: Lock }, + DoesNotExistError: { title: "Not Found", icon: FileQuestion }, + AuthenticationError: { title: "Login Required", icon: Lock }, +}; + +const meta = computed( + () => + (props.excType && META[props.excType]) || { + title: "Something Went Wrong", + icon: AlertTriangle, + } +); +// Escape all HTML, then re-allow a safe set of inline formatting tags so the +// backend's <strong>…</strong> renders bold without exposing an XSS vector +// (the message embeds the user-controlled email). +const ALLOWED_TAGS = ["strong", "b", "em", "i"]; + +const message = computed(() => { + const raw = props.messages?.[0] ?? ""; + const escaped = raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + return escaped.replace(new RegExp(`<(/?)(${ALLOWED_TAGS.join("|")})>`, "gi"), "<$1$2>"); +}); +</script> + +<template> + <div class="flex flex-col items-center justify-center h-full gap-3 p-8 text-center"> + <component :is="meta.icon" class="w-12 h-12 text-ink-gray-5" /> + <h2 class="text-xl font-semibold">{{ meta.title }}</h2> + <p v-if="message" class="text-sm text-ink-gray-6 max-w-md" v-html="message"></p> + <p v-if="httpStatus" class="text-xs text-ink-gray-4">HTTP {{ httpStatus }}</p> + <Button @click="router.replace('/')">Go to Dashboard</Button> + </div> +</template> diff --git a/frontend/src/components/RouteProgress.vue b/frontend/src/components/RouteProgress.vue new file mode 100644 index 0000000..ac878fc --- /dev/null +++ b/frontend/src/components/RouteProgress.vue @@ -0,0 +1,40 @@ +<script setup lang="ts"> +import { useRouteData } from "@/stores/routeData"; + +const routeData = useRouteData(); +</script> + +<template> + <div + v-if="routeData.isNavigating" + role="progressbar" + aria-busy="true" + aria-label="Loading" + class="route-progress fixed top-0 left-0 right-0 h-[2px] z-[100] overflow-hidden bg-surface-gray-2" + > + <div class="route-progress__bar h-full bg-surface-gray-7" /> + </div> +</template> + +<style scoped> +.route-progress__bar { + width: 40%; + animation: route-progress-slide 1.1s linear infinite; + will-change: transform; +} +@keyframes route-progress-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(350%); + } +} +@media (prefers-reduced-motion: reduce) { + .route-progress__bar { + animation: none; + width: 100%; + opacity: 0.6; + } +} +</style> diff --git a/frontend/src/components/builder/ColumnDropZone.vue b/frontend/src/components/builder/ColumnDropZone.vue new file mode 100644 index 0000000..dce162f --- /dev/null +++ b/frontend/src/components/builder/ColumnDropZone.vue @@ -0,0 +1,69 @@ +<script setup lang="ts"> +import draggableComponent from "vuedraggable"; +import type { FormField } from "@/types/formfield"; +import { ref, computed, onMounted, onUnmounted, nextTick } from "vue"; + +const props = defineProps<{ + atRow: number; + atCol: number; + isDragging: boolean; +}>(); + +const emit = defineEmits<{ + drop: [field: FormField, atRow: number, atCol: number]; +}>(); + +const buffer = ref<FormField[]>([]); +const draggableRef = ref<any>(null); +const isOver = ref(false); +let observer: MutationObserver | null = null; + +onMounted(() => { + const el = draggableRef.value?.$el; + if (!el) return; + observer = new MutationObserver(() => { + isOver.value = el.children.length > 0; + }); + observer.observe(el, { childList: true }); +}); + +onUnmounted(() => { + observer?.disconnect(); +}); + +const isHighlighted = computed(() => props.isDragging && isOver.value); + +async function onZoneChange(evt: any) { + if (evt.added) { + emit("drop", evt.added.element, props.atRow, props.atCol); + await nextTick(); + buffer.value = []; + } +} +</script> + +<template> + <draggableComponent + ref="draggableRef" + :list="buffer" + :group="{ name: 'fields', put: true, pull: false }" + item-key="fieldname" + tag="div" + :force-fallback="true" + data-form-builder-component="column-drop-zone" + :data-at-row="props.atRow" + :data-at-col="props.atCol" + :class="[ + 'relative w-3 self-stretch', + 'before:content-[\'\'] before:absolute before:inset-y-0 before:left-1/2 before:-translate-x-1/2 before:rounded-full before:transition-[width,background-color] before:duration-150 before:ease-out motion-reduce:before:transition-none', + isHighlighted + ? 'before:w-1 before:bg-surface-blue-3' + : isDragging + ? 'before:w-px before:bg-surface-blue-1' + : 'before:w-0 before:bg-transparent', + ]" + @change="onZoneChange" + > + <template #item="{}"></template> + </draggableComponent> +</template> diff --git a/frontend/src/components/builder/FieldActions.vue b/frontend/src/components/builder/FieldActions.vue index 32f67df..6befe0b 100644 --- a/frontend/src/components/builder/FieldActions.vue +++ b/frontend/src/components/builder/FieldActions.vue @@ -1,14 +1,16 @@ <script setup lang="ts"> -import { GripVertical, Trash2 } from "lucide-vue-next"; +import { GripVertical, SquareSplitVertical, Trash2 } from "@lucide/vue"; import { Button } from "frappe-ui"; defineProps<{ isSelected: boolean; isDraggingAnyField: boolean; + canEject: boolean; }>(); defineEmits<{ (e: "remove"): void; + (e: "eject"): void; }>(); </script> <template> @@ -22,6 +24,15 @@ defineEmits<{ : 'opacity-0 scale-90 group-hover:opacity-100 group-hover:scale-100', ]" > + <Button + v-if="canEject" + size="sm" + :icon="SquareSplitVertical" + variant="ghost" + @click.stop="$emit('eject')" + tooltip="Move to own row" + data-form-builder-component="eject-button" + /> <Button size="sm" :icon="Trash2" diff --git a/frontend/src/components/builder/FieldCard.vue b/frontend/src/components/builder/FieldCard.vue new file mode 100644 index 0000000..2fd4eec --- /dev/null +++ b/frontend/src/components/builder/FieldCard.vue @@ -0,0 +1,57 @@ +<script setup lang="ts"> +import { computed } from "vue"; +import type { FormField } from "@/types/formfield"; +import { useEditForm } from "@/stores/editForm"; +import FieldActions from "@/components/builder/FieldActions.vue"; +import FieldRenderer from "@/components/builder/FieldRenderer.vue"; + +const props = defineProps<{ + field: FormField; + isDraggingAnyField: boolean; +}>(); + +const editFormStore = useEditForm(); + +// Eject = "Move to own row". Available whenever the row holds more than one +// cell — that is, more than one column OR more than one stacked cell within +// a single column. (Variable was previously named `isMultiColumn`, which +// understated the stacked-cell case.) +const canEject = computed( + () => + editFormStore.fields.filter( + (f: FormField) => (f.row_index ?? 0) === (props.field.row_index ?? 0) + ).length > 1 +); + +function ejectToOwnRow() { + editFormStore.insertNewRow(props.field, (props.field.row_index ?? 0) + 1); +} +</script> + +<template> + <div + class="relative flex-1 min-w-0 transition-colors group" + data-form-builder-component="field-card" + :data-field-label="props.field.label ?? ''" + :data-field-name="props.field.fieldname ?? ''" + :data-row-index="props.field.row_index ?? 0" + :data-col-index="props.field.column_index ?? 0" + :data-cell-index="props.field.cell_index ?? 0" + @click="editFormStore.selectField(props.field)" + > + <FieldActions + :isSelected="editFormStore.selectedField === props.field" + :isDraggingAnyField="props.isDraggingAnyField" + :canEject="canEject" + @remove="editFormStore.removeField(props.field)" + @eject="ejectToOwnRow" + /> + <FieldRenderer + :field="props.field" + :inEditMode="true" + @update:field=" + (updated: FormField) => editFormStore.updateField(props.field, updated) + " + /> + </div> +</template> diff --git a/frontend/src/components/builder/FieldLabel.vue b/frontend/src/components/builder/FieldLabel.vue new file mode 100644 index 0000000..211d3a4 --- /dev/null +++ b/frontend/src/components/builder/FieldLabel.vue @@ -0,0 +1,30 @@ +<script setup lang="ts"> +import { Asterisk } from "@lucide/vue"; + +defineProps<{ + field: { + label?: string; + reqd?: boolean; + }; + inEditMode: boolean; +}>(); + +const emit = defineEmits<{ + "update:label": [value: string]; +}>(); +</script> + +<template> + <div class="flex gap-2 items-start"> + <input + v-if="inEditMode" + placeholder="Label" + type="text" + :value="field.label" + @input="emit('update:label', ($event.target as HTMLInputElement).value)" + class="bg-transparent border-none outline-none text-base focus:ring-0 w-full px-0 py-1" + /> + <label class="text-base" v-else>{{ field.label }}</label> + <Asterisk v-if="field.reqd" class="w-4 h-4 text-red-400" /> + </div> +</template> diff --git a/frontend/src/components/builder/FieldRenderer.vue b/frontend/src/components/builder/FieldRenderer.vue index 09a7696..b6f1cec 100644 --- a/frontend/src/components/builder/FieldRenderer.vue +++ b/frontend/src/components/builder/FieldRenderer.vue @@ -1,11 +1,11 @@ <script setup lang="ts"> import { computed } from "vue"; -import { Asterisk } from "lucide-vue-next"; import RenderField from "../RenderField.vue"; +import FieldLabel from "./FieldLabel.vue"; import Heading from "@/components/fields/Heading.vue"; import Table from "@/components/fields/Table.vue"; import { useFieldOptions } from "@/utils/selectOptions"; -import { getFieldTypeDef, type Fieldtype } from "@/config/fieldTypes"; +import { getFieldTypeDef, Fieldtype } from "@/config/fieldTypes"; const props = defineProps({ field: { @@ -24,11 +24,9 @@ const props = defineProps({ }); const emit = defineEmits(["update:field"]); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const modelValue = defineModel<any>(); -// Add v-model support -const modelValue = defineModel(); - -// Create a computed property for two-way binding const fieldData = computed({ get() { return props.field; @@ -38,18 +36,9 @@ const fieldData = computed({ }, }); -const getClasses = computed(() => { - switch (fieldData.value.fieldtype) { - case "Text Editor": - return "w-fit"; - case "Switch": - return "w-full flex gap-4 my-2"; - case "Checkbox": - return "w-full flex gap-2"; - default: - return "w-full flex flex-col gap-2"; - } -}); +const layout = computed( + () => getFieldTypeDef(fieldData.value.fieldtype as Fieldtype)?.layout ?? "default" +); const builderExtrasComponent = computed( () => getFieldTypeDef(fieldData.value.fieldtype as Fieldtype)?.builderExtras ?? null @@ -57,150 +46,86 @@ const builderExtrasComponent = computed( const { options: selectOptions } = useFieldOptions(fieldData); </script> + <template> <div class="w-full flex flex-col gap-2"> - <div :class="getClasses" v-if="fieldData.fieldtype == 'Switch'"> + <!-- inline: Switch / Checkbox — input precedes label --> + <div v-if="layout === 'inline'" class="w-full flex gap-2 my-2"> <RenderField v-model="modelValue" :field="fieldData" - :class="{ 'pointer-events-none': inEditMode }" + :class="{ 'pointer-events-none mt-1': inEditMode }" :disabled="disabled" /> - <div class="flex flex-col gap-1"> - <div class="flex gap-2 items-start"> - <input - v-if="inEditMode" - placeholder="Label" - type="text" - v-model="fieldData.label" - class="bg-transparent border-none outline-none text-base focus:ring-0 w-fit px-0 py-1" - /> - <label class="text-base" v-else>{{ fieldData.label }}</label> - <Asterisk v-if="fieldData.reqd" class="w-4 h-4 text-red-400" /> - </div> - <small class="text-gray-500"> - {{ fieldData.description }} - </small> + <div class="flex flex-col gap-1 w-full"> + <FieldLabel + :field="fieldData" + :in-edit-mode="inEditMode" + @update:label="fieldData.label = $event" + /> + <small class="text-gray-500">{{ fieldData.description }}</small> </div> </div> - <div :class="getClasses" v-else-if="fieldData.fieldtype == 'Checkbox'"> + + <!-- description-first: label → description → input (Text Editor, Multiselect) --> + <div v-else-if="layout === 'description-first'" class="flex flex-col gap-1"> + <FieldLabel + :field="fieldData" + :in-edit-mode="inEditMode" + @update:label="fieldData.label = $event" + /> + <small class="text-gray-500">{{ fieldData.description }}</small> <RenderField v-model="modelValue" :field="fieldData" - :class="{ 'pointer-events-none mt-1': inEditMode }" + :in-edit-mode="inEditMode" + :class="{ 'pointer-events-none': inEditMode }" :disabled="disabled" /> - <div class="flex flex-col gap-1 w-full"> - <div class="flex gap-2 items-start"> - <input - v-if="inEditMode" - :id="fieldData.name + '_label'" - placeholder="Label" - type="text" - v-model="fieldData.label" - class="bg-transparent border-none outline-none text-base focus:ring-0 px-0 py-1 !w-full" - /> - <label class="text-base" v-else>{{ fieldData.label }}</label> - <Asterisk v-if="fieldData.reqd" class="w-4 h-4 text-red-400" /> - </div> - <small v-if="fieldData.description" class="text-gray-500"> - {{ fieldData.description }} - </small> - </div> </div> - <div v-else-if="fieldData.fieldtype == 'Text Editor'"> - <div class="flex flex-col gap-1"> - <div class="flex gap-2 items-start"> - <input - v-if="inEditMode" - placeholder="Label" - type="text" - v-model="fieldData.label" - class="bg-transparent border-none outline-none text-base focus:ring-0 w-fit px-0 py-1" - /> - <label class="text-base" v-else>{{ fieldData.label }}</label> - <Asterisk v-if="fieldData.reqd" class="w-4 h-4 text-red-400" /> - </div> - <small class="text-gray-500"> - {{ fieldData.description }} - </small> - <RenderField - :model-value="modelValue" - :field="fieldData" - @change="(value: any) => (modelValue = value)" - :class="{ 'pointer-events-none': inEditMode }" - :disabled="disabled" - /> - </div> + + <!-- heading: renders field label as h1/h2/h3; no input --> + <div v-else-if="layout === 'heading'" class="w-full py-1"> + <Heading + :field="fieldData" + :in-edit-mode="inEditMode" + @update:label="fieldData.label = $event" + /> </div> - <div v-else-if="fieldData.fieldtype == 'Attach'"> - <div class="flex gap-2 items-start"> - <input - v-if="inEditMode" - placeholder="Label" - type="text" - v-model="fieldData.label" - class="bg-transparent border-none outline-none text-base focus:ring-0 w-fit px-0 py-1" - /> - <label class="text-base" v-else>{{ fieldData.label }}</label> - <Asterisk v-if="fieldData.reqd" class="w-4 h-4 text-red-400" /> - </div> - <RenderField - :value="modelValue" - @update:value="(value: string) => (modelValue = value)" + + <!-- custom: Attach and Table each need their own binding/widget --> + <div v-else-if="layout === 'custom' && fieldData.fieldtype === 'Attach'"> + <FieldLabel :field="fieldData" :in-edit-mode="inEditMode" + @update:label="fieldData.label = $event" + /> + <RenderField + v-model="modelValue" + :field="fieldData" :class="{ 'pointer-events-none': inEditMode }" :disabled="disabled" /> - <small class="text-gray-500"> - {{ fieldData.description }} - </small> + <small class="text-gray-500">{{ fieldData.description }}</small> </div> - <div v-else-if="fieldData.fieldtype == 'Table'" class="w-full space-y-4"> - <div class="flex gap-2 items-start"> - <input - v-if="inEditMode" - placeholder="Label" - type="text" - v-model="fieldData.label" - class="bg-transparent border-none outline-none text-base focus:ring-0 w-fit px-0 py-1" - /> - <label class="text-base" v-else>{{ fieldData.label }}</label> - <Asterisk v-if="fieldData.reqd" class="w-4 h-4 text-red-400" /> - </div> - <small class="text-gray-500"> - {{ fieldData.description }} - </small> - <Table - v-model="modelValue as undefined" + + <div v-else-if="layout === 'custom'" class="w-full space-y-4"> + <FieldLabel + :field="fieldData" :in-edit-mode="inEditMode" - :doctype="fieldData.options" + @update:label="fieldData.label = $event" /> + <small class="text-gray-500">{{ fieldData.description }}</small> + <Table v-model="modelValue" :in-edit-mode="inEditMode" :doctype="fieldData.options" /> </div> - <!-- heading: renders field label as h1/h2/h3; no input --> - <div - v-else-if="['Heading 1', 'Heading 2', 'Heading 3'].includes(fieldData.fieldtype)" - class="w-full py-1" - > - <Heading + + <!-- default: label → input → description --> + <div v-else class="w-full flex flex-col gap-2"> + <FieldLabel :field="fieldData" :in-edit-mode="inEditMode" @update:label="fieldData.label = $event" /> - </div> - <div v-else :class="getClasses"> - <div class="flex gap-2 items-start"> - <input - v-if="inEditMode" - placeholder="Label" - type="text" - v-model="fieldData.label" - class="bg-transparent border-none outline-none text-base focus:ring-0 w-fit px-0 py-1" - /> - <label class="text-base" v-else>{{ fieldData.label }}</label> - <Asterisk v-if="fieldData.reqd" class="w-4 h-4 text-red-400" /> - </div> <RenderField v-model="modelValue" :field="fieldData" @@ -208,10 +133,9 @@ const { options: selectOptions } = useFieldOptions(fieldData); :disabled="disabled" :options="selectOptions" /> - <small class="text-gray-500"> - {{ fieldData.description }} - </small> + <small class="text-gray-500">{{ fieldData.description }}</small> </div> + <component v-if="inEditMode && builderExtrasComponent" :is="builderExtrasComponent" diff --git a/frontend/src/components/builder/RowDropZone.vue b/frontend/src/components/builder/RowDropZone.vue new file mode 100644 index 0000000..812b3e2 --- /dev/null +++ b/frontend/src/components/builder/RowDropZone.vue @@ -0,0 +1,69 @@ +<script setup lang="ts"> +import draggableComponent from "vuedraggable"; +import type { FormField } from "@/types/formfield"; +import { ref, computed, onMounted, onUnmounted, nextTick } from "vue"; + +const props = defineProps<{ + atRow: number; + isDragging: boolean; +}>(); + +const emit = defineEmits<{ + drop: [field: FormField, atRow: number]; +}>(); + +const buffer = ref<FormField[]>([]); +const draggableRef = ref<any>(null); +const isOver = ref(false); +let observer: MutationObserver | null = null; + +// SortableJS inserts a placeholder child into the target list while hovering. +// Watching for that insertion is more reliable than pointer events during drag. +onMounted(() => { + const el = draggableRef.value?.$el; + if (!el) return; + observer = new MutationObserver(() => { + isOver.value = el.children.length > 0; + }); + observer.observe(el, { childList: true }); +}); + +onUnmounted(() => { + observer?.disconnect(); +}); + +const isHighlighted = computed(() => props.isDragging && isOver.value); + +async function onZoneChange(evt: any) { + if (evt.added) { + emit("drop", evt.added.element, props.atRow); + await nextTick(); + buffer.value = []; + } +} +</script> + +<template> + <draggableComponent + ref="draggableRef" + :list="buffer" + :group="{ name: 'fields', put: true, pull: false }" + item-key="fieldname" + tag="div" + :force-fallback="true" + data-form-builder-component="row-drop-zone" + :data-at-row="props.atRow" + :class="[ + 'relative h-3 w-full', + 'before:content-[\'\'] before:absolute before:inset-x-0 before:top-1/2 before:-translate-y-1/2 before:rounded-full before:transition-[height,background-color] before:duration-150 before:ease-out motion-reduce:before:transition-none', + isHighlighted + ? 'before:h-1 before:bg-surface-blue-3' + : isDragging + ? 'before:h-px before:bg-surface-blue-1' + : 'before:h-0 before:bg-transparent', + ]" + @change="onZoneChange" + > + <template #item="{}"></template> + </draggableComponent> +</template> diff --git a/frontend/src/components/builder/field-editor/ConditionalLogicSection.vue b/frontend/src/components/builder/field-editor/ConditionalLogicSection.vue index 19f9049..b182223 100644 --- a/frontend/src/components/builder/field-editor/ConditionalLogicSection.vue +++ b/frontend/src/components/builder/field-editor/ConditionalLogicSection.vue @@ -7,7 +7,7 @@ import { type Condition, Actions, } from "@/types/conditional-render.types"; -import { EllipsisVertical, Trash, Workflow, Zap } from "lucide-vue-next"; +import { EllipsisVertical, Trash, Workflow, Zap } from "@lucide/vue"; import { FormControl, Popover } from "frappe-ui"; import type { FormField } from "@/types/formfield"; diff --git a/frontend/src/components/builder/field-editor/FieldPropertiesForm.vue b/frontend/src/components/builder/field-editor/FieldPropertiesForm.vue index 4183359..38cc19a 100644 --- a/frontend/src/components/builder/field-editor/FieldPropertiesForm.vue +++ b/frontend/src/components/builder/field-editor/FieldPropertiesForm.vue @@ -1,7 +1,8 @@ <script setup lang="ts"> import { useEditForm } from "@/stores/editForm"; import { FormControl } from "frappe-ui"; -import { FormField, FormFieldTypes } from "@/types/formfield"; +import { FormField } from "@/types/formfield"; +import { FIELD_TYPE_DEFINITIONS } from "@/config/fieldTypes"; import { computed } from "vue"; import type { Component } from "vue"; import ConditionalLogicSection from "./ConditionalLogicSection.vue"; @@ -44,7 +45,7 @@ const fieldProperties = computed(() => { label: "Fieldtype", required: true, variant: "outline", - options: Object.values(FormFieldTypes), + options: FIELD_TYPE_DEFINITIONS.map((d) => d.name), }, }, { diff --git a/frontend/src/components/builder/sidebar/AddFieldsSection.vue b/frontend/src/components/builder/sidebar/AddFieldsSection.vue index 2ebad2e..3b0af22 100644 --- a/frontend/src/components/builder/sidebar/AddFieldsSection.vue +++ b/frontend/src/components/builder/sidebar/AddFieldsSection.vue @@ -1,21 +1,14 @@ <script setup lang="ts"> import { ref, computed } from "vue"; -import { formFields, FormFields } from "@/utils/form_fields"; -import { FormControl, Button } from "frappe-ui"; +import { formFields, type FormFields } from "@/utils/form_fields"; +import { FormControl } from "frappe-ui"; import { useEditForm } from "@/stores/editForm"; -import RenderField from "@/components/RenderField.vue"; -import type { Component } from "vue"; const search = ref(""); -const componentMap = formFields.reduce((acc: Record<string, Component>, field: FormFields) => { - acc[field.name] = field.component; - return acc; -}, {}); -const filteredComponents = computed(() => { - return Object.keys(componentMap).filter((component) => - component.toLowerCase().includes(search.value.toLowerCase()) - ); +const filteredFields = computed(() => { + const q = search.value.toLowerCase(); + return formFields.filter((field: FormFields) => field.name.toLowerCase().includes(q)); }); const editFormStore = useEditForm(); @@ -30,19 +23,24 @@ const editFormStore = useEditForm(); variant="outline" placeholder="Search Fields" /> - <div v-for="component in filteredComponents" :key="component"> - <div - class="p-2 bg-gray-50 w-full rounded flex flex-col gap-2 border border-gray-200 hover:border-gray-400 transition-all relative group" + <p v-if="!filteredFields.length" class="text-sm text-gray-500 px-1 py-2"> + No fields match "{{ search }}" + </p> + <div v-else class="flex flex-col gap-2"> + <button + v-for="field in filteredFields" + :key="field.name" + type="button" + class="flex w-full items-center gap-2 px-2.5 py-2 bg-surface-gray-1 rounded border border-outline-gray-1 hover:border-outline-gray-2 hover:bg-surface-gray-2 active:scale-[0.98] active:bg-surface-gray-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-1 transition-all duration-150 text-left" + @click="editFormStore.addField(field.name)" > - <div class="text-sm">{{ component }}</div> - <RenderField class="pointer-events-none" :field="{ fieldtype: component }" /> - <Button - class="absolute top-4 -right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10" - variant="outline" - icon="plus" - @click="editFormStore.addField(component)" + <component + :is="field.icon" + class="w-4 h-4 text-gray-600 shrink-0" + aria-hidden="true" /> - </div> + <span class="text-sm truncate">{{ field.name }}</span> + </button> </div> </div> </div> diff --git a/frontend/src/components/builder/sidebar/SettingsSection.vue b/frontend/src/components/builder/sidebar/SettingsSection.vue index 7d9798b..88b6dab 100644 --- a/frontend/src/components/builder/sidebar/SettingsSection.vue +++ b/frontend/src/components/builder/sidebar/SettingsSection.vue @@ -3,7 +3,7 @@ import { Checkbox, Dialog, FormControl, TextEditor, Tooltip } from "frappe-ui"; import { useEditForm } from "@/stores/editForm"; import { validateFormRoute } from "@/utils/form_generator"; import { ref, watch } from "vue"; -import { CircleCheck } from "lucide-vue-next"; +import { CircleCheck } from "@lucide/vue"; const editFormStore = useEditForm(); diff --git a/frontend/src/components/dashboard/FormPreviewCard.vue b/frontend/src/components/dashboard/FormPreviewCard.vue index 78bae61..8172288 100644 --- a/frontend/src/components/dashboard/FormPreviewCard.vue +++ b/frontend/src/components/dashboard/FormPreviewCard.vue @@ -1,6 +1,6 @@ <script setup lang="ts"> import { Badge } from "frappe-ui"; -import { FileText } from "lucide-vue-next"; +import { FileText } from "@lucide/vue"; import { computed } from "vue"; const props = defineProps({ diff --git a/frontend/src/components/fields/Attachment.vue b/frontend/src/components/fields/Attachment.vue index effaad4..aab9714 100644 --- a/frontend/src/components/fields/Attachment.vue +++ b/frontend/src/components/fields/Attachment.vue @@ -2,16 +2,13 @@ import { ErrorMessage, FileUploader } from "frappe-ui"; import { ref } from "vue"; -const props = defineProps({ +defineProps({ field: { type: Object, required: true, }, }); -const emit = defineEmits(["update:value"]); - -// @ts-ignore const value = defineModel<string>(); const inPreview = ref(false); @@ -51,24 +48,20 @@ const formatFileSize = (fileSize: number) => { const handleChange = (file: FileType) => { fileData.value = file; if (file.file_url) { - emit("update:value", file.file_url); + value.value = file.file_url; } inPreview.value = true; }; const handleRemove = () => { fileData.value = null; - emit("update:value", ""); + value.value = ""; inPreview.value = false; }; </script> <template> <div class="flex gap-2 flex-col mt-2"> - <FileUploader - v-if="!inPreview" - v-bind="props.field" - @success="(file: FileType) => handleChange(file)" - > + <FileUploader v-if="!inPreview" @success="(file: FileType) => handleChange(file)"> <!-- @vue-ignore --> <template #default="{ uploading, progress, error, openFileSelector }"> <Button @click="openFileSelector()" :loading="uploading"> diff --git a/frontend/src/components/fields/Phone.vue b/frontend/src/components/fields/Phone.vue index 1954b85..c3d80f7 100644 --- a/frontend/src/components/fields/Phone.vue +++ b/frontend/src/components/fields/Phone.vue @@ -1,7 +1,7 @@ <script setup lang="ts"> import { computed, ref, watch, onMounted } from "vue"; import { FormControl } from "frappe-ui"; -import { ChevronDown, ChevronUp } from "lucide-vue-next"; +import { ChevronDown, ChevronUp } from "@lucide/vue"; import { onClickOutside } from "@vueuse/core"; export type Country = { diff --git a/frontend/src/components/fields/Rating.vue b/frontend/src/components/fields/Rating.vue new file mode 100644 index 0000000..5b006b8 --- /dev/null +++ b/frontend/src/components/fields/Rating.vue @@ -0,0 +1,34 @@ +<script setup lang="ts"> +import { computed } from "vue"; +import { Rating as FrappeRating } from "frappe-ui"; + +// Frappe stores Rating as a 0..1 fraction (see `_fix_rating_value` in +// frappe.model.document — values are clamped to [0, 1]). frappe-ui's Rating +// component, however, works in raw 1..N integers. Passing a 0..1 float in or +// emitting a 1..N int out silently corrupts the value. This wrapper bridges +// the two representations so the underlying fraction round-trips intact. + +const MAX_STARS = 5; + +defineProps<{ + readonly?: boolean; + disabled?: boolean; +}>(); + +const modelValue = defineModel<number | null>(); + +const starValue = computed(() => Math.round((Number(modelValue.value) || 0) * MAX_STARS)); + +const onUpdate = (stars: number) => { + modelValue.value = stars / MAX_STARS; +}; +</script> + +<template> + <FrappeRating + :modelValue="starValue" + :rating_from="MAX_STARS" + :readonly="readonly || disabled" + @update:modelValue="onUpdate" + /> +</template> diff --git a/frontend/src/components/form/submissions/SubmissionFieldValue.vue b/frontend/src/components/form/submissions/SubmissionFieldValue.vue index 18b0f22..dcbb56f 100644 --- a/frontend/src/components/form/submissions/SubmissionFieldValue.vue +++ b/frontend/src/components/form/submissions/SubmissionFieldValue.vue @@ -1,8 +1,7 @@ <script setup lang="ts"> import { Checkbox, Switch, Rating, TextEditor } from "frappe-ui"; -import { FormFieldTypes } from "@/types/formfield"; +import { Fieldtype } from "@/types/formfield"; import { getFieldTypeDef } from "@/config/fieldTypes"; -import { Fieldtype } from "@/types/FormsPro/form_field.types"; import { formatDate, formatDateTime, formatTime } from "@/utils/date"; import { computed } from "vue"; import { isHeading } from "@/utils/form_fields"; @@ -12,20 +11,20 @@ const props = defineProps<{ fieldname: string; label: string; description?: string; - fieldtype: FormFieldTypes; + fieldtype: Fieldtype; value: any; }>(); const formattedDateValue = computed(() => { if (!props.value) return ""; switch (props.fieldtype) { - case FormFieldTypes.Date: + case Fieldtype.DATE: return formatDate(props.value); - case FormFieldTypes.DateTime: + case Fieldtype.DATE_TIME: return formatDateTime(props.value); - case FormFieldTypes.TimePicker: + case Fieldtype.TIME_PICKER: return formatTime(props.value); - case FormFieldTypes.DateRange: + case Fieldtype.DATE_RANGE: try { const dates = JSON.parse(props.value); if (Array.isArray(dates) && dates.length === 2) { @@ -40,7 +39,7 @@ const formattedDateValue = computed(() => { } }); -const typeDef = computed(() => getFieldTypeDef(props.fieldtype as unknown as Fieldtype)); +const typeDef = computed(() => getFieldTypeDef(props.fieldtype)); const isDateField = computed(() => typeDef.value?.isDate ?? false); const parsedMultiselectValue = computed(() => { @@ -78,26 +77,22 @@ const classNames = computed<string>(() => <template> <div :class="classNames"> - <div v-if="!isHeading(fieldtype as unknown as Fieldtype)"> + <div v-if="!isHeading(fieldtype)"> <span class="text-sm text-ink-gray-5">{{ label }}</span> <p v-if="description" class="text-xs text-ink-gray-4">{{ description }}</p> </div> <Checkbox - v-if="fieldtype === FormFieldTypes.Checkbox" + v-if="fieldtype === Fieldtype.CHECKBOX" class="mt-1" :modelValue="Boolean(value)" disabled /> - <Switch - v-else-if="fieldtype === FormFieldTypes.Switch" - :modelValue="Boolean(value)" - disabled - /> + <Switch v-else-if="fieldtype === Fieldtype.SWITCH" :modelValue="Boolean(value)" disabled /> <TextEditor - v-else-if="fieldtype === FormFieldTypes.TextEditor" + v-else-if="fieldtype === Fieldtype.TEXT_EDITOR" :content="value" :editable="false" :bubbleMenu="false" @@ -105,23 +100,32 @@ const classNames = computed<string>(() => editorClass="prose-sm !border-none !p-0 !shadow-none" /> - <Rating v-else-if="fieldtype === FormFieldTypes.Rating" :modelValue="value" readonly /> + <Rating + v-else-if="fieldtype === Fieldtype.RATING" + :modelValue="Math.round((Number(value) || 0) * 5)" + :rating_from="5" + readonly + /> <a - v-else-if="fieldtype === FormFieldTypes.Attach && safeAttachUrl" + v-else-if="fieldtype === Fieldtype.ATTACH && safeAttachUrl" :href="safeAttachUrl" target="_blank" + rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-700 underline truncate" > {{ value }} </a> + <span v-else-if="fieldtype === Fieldtype.ATTACH && value" class="text-sm text-ink-gray-5"> + {{ value }} + </span> - <span v-else-if="fieldtype === FormFieldTypes.Multiselect" class="text-sm text-ink-gray-7"> + <span v-else-if="fieldtype === Fieldtype.MULTISELECT" class="text-sm text-ink-gray-7"> {{ parsedMultiselectValue }} </span> <span - v-else-if="fieldtype === FormFieldTypes.Textarea" + v-else-if="fieldtype === Fieldtype.TEXTAREA" class="text-sm text-ink-gray-7 whitespace-pre-wrap" > {{ value ?? "–" }} @@ -132,8 +136,8 @@ const classNames = computed<string>(() => </span> <Heading - v-else-if="isHeading(fieldtype as unknown as Fieldtype)" - :field="{ label, fieldtype: fieldtype as unknown as Fieldtype }" + v-else-if="isHeading(fieldtype)" + :field="{ label, fieldtype }" :in-edit-mode="false" /> diff --git a/frontend/src/components/form/submissions/SubmissionList.vue b/frontend/src/components/form/submissions/SubmissionList.vue index 8600c45..40d4758 100644 --- a/frontend/src/components/form/submissions/SubmissionList.vue +++ b/frontend/src/components/form/submissions/SubmissionList.vue @@ -1,6 +1,7 @@ <script setup lang="ts"> import { useManageForm } from "@/stores/form/manageForm"; -import { ListView, Badge, createResource } from "frappe-ui"; +import { FileText, Sheet, Download } from "@lucide/vue"; +import { ListView, Badge, createResource, Dropdown } from "frappe-ui"; import { formatDateTime } from "@/utils/date"; import Avatar from "@/components/ui/Avatar.vue"; import Drawer from "@/components/ui/Drawer.vue"; @@ -64,8 +65,40 @@ const columns = computed(() => [ width: 1, }, ]); + +const onExport = (fileType: "CSV" | "Excel") => { + const formId = manageFormStore.currentFormId; + if (!formId) { + toast.error("Unable to export", { description: "No form selected." }); + return; + } + const params = new URLSearchParams({ form_id: formId, file_type: fileType }); + window.location.href = `/api/method/forms_pro.api.export.export_submissions?${params}`; +}; + +const dropdownItems = computed(() => [ + { + label: "Export as CSV", + icon: FileText, + onClick: () => onExport("CSV"), + }, + { + label: "Export as Excel", + icon: Sheet, + onClick: () => onExport("Excel"), + }, +]); </script> <template> + <div> + <Dropdown + :button="{ + label: 'Export', + iconLeft: Download, + }" + :options="dropdownItems" + /> + </div> <ListView :columns="columns" :rows="allSubmissions" diff --git a/frontend/src/components/submission/FormRenderer.vue b/frontend/src/components/submission/FormRenderer.vue index e7190a6..99b7d63 100644 --- a/frontend/src/components/submission/FormRenderer.vue +++ b/frontend/src/components/submission/FormRenderer.vue @@ -4,6 +4,7 @@ import { useSubmissionForm } from "@/stores/submissionForm"; import FieldRenderer from "@/components/builder/FieldRenderer.vue"; import { computed } from "vue"; import { shouldFieldBeVisible, shouldFieldBeRequired } from "@/utils/conditionals"; +import { useGroupedRows } from "@/composables/useGroupedRows"; import type { FormField } from "@/types/formfield"; const submissionFormStore = useSubmissionForm(); @@ -17,14 +18,21 @@ const props = withDefaults( } ); -// Computed property to get visible fields based on conditional logic -// This will automatically update when form values change -const visibleFields = computed(() => { - const fields = submissionFormStore.formResource.data?.fields || []; - return fields.filter((field: FormField) => - shouldFieldBeVisible(field, submissionFormStore.fields, fields) - ); -}); +const allFields = computed<FormField[]>(() => submissionFormStore.formResource.data?.fields || []); + +const groupedRows = useGroupedRows(allFields); + +function isFieldVisible(field: FormField) { + return shouldFieldBeVisible(field, submissionFormStore.fields, allFields.value); +} + +function rowKey(row: FormField[][], rIdx: number) { + return `r-${row[0]?.[0]?.row_index ?? rIdx}`; +} + +function colKey(col: FormField[], cIdx: number) { + return `c-${col[0]?.column_index ?? cIdx}`; +} function handleSubmitForm() { submissionFormStore.submitForm(); @@ -35,21 +43,39 @@ function handleSubmitForm() { <LoadingIndicator class="mx-auto my-auto w-5 h-5" /> </div> <div v-if="submissionFormStore.inFormFillingState" class="flex flex-col gap-4"> - <div v-for="field in visibleFields" :key="field.fieldname"> - <FieldRenderer - :disabled="disabled" - v-model="submissionFormStore.fields[field.fieldname]" - :field="{ - ...field, - reqd: shouldFieldBeRequired( - field, - submissionFormStore.fields, - submissionFormStore.formResource.data?.fields || [] - ), - }" - :inEditMode="false" - /> - </div> + <template v-for="(row, rIdx) in groupedRows" :key="rowKey(row, rIdx)"> + <div + v-if="row.some((col) => col.some(isFieldVisible))" + class="flex flex-col md:flex-row gap-4" + data-form-renderer-component="form-row" + > + <template v-for="(col, cIdx) in row" :key="colKey(col, cIdx)"> + <div + v-if="col.some(isFieldVisible)" + class="flex flex-col gap-4 flex-1 min-w-0" + data-form-renderer-component="form-column" + > + <template v-for="field in col" :key="field.fieldname"> + <div v-if="isFieldVisible(field)"> + <FieldRenderer + :disabled="disabled" + v-model="submissionFormStore.fields[field.fieldname]" + :field="{ + ...field, + reqd: shouldFieldBeRequired( + field, + submissionFormStore.fields, + allFields + ), + }" + :inEditMode="false" + /> + </div> + </template> + </div> + </template> + </div> + </template> <hr /> <ErrorMessage :message="submissionFormStore.errors.join('\n')" /> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full"> diff --git a/frontend/src/components/submission/FormUnpublishedState.vue b/frontend/src/components/submission/FormUnpublishedState.vue index e1bea2f..24c823a 100644 --- a/frontend/src/components/submission/FormUnpublishedState.vue +++ b/frontend/src/components/submission/FormUnpublishedState.vue @@ -1,5 +1,5 @@ <script setup lang="ts"> -import { Unplug } from "lucide-vue-next"; +import { Unplug } from "@lucide/vue"; </script> <template> diff --git a/frontend/src/components/team/TeamSwitcher.vue b/frontend/src/components/team/TeamSwitcher.vue index 02b566b..4fc5f30 100644 --- a/frontend/src/components/team/TeamSwitcher.vue +++ b/frontend/src/components/team/TeamSwitcher.vue @@ -1,7 +1,7 @@ <script setup lang="ts"> import { useUser } from "@/stores/user"; import { Dropdown, TextInput } from "frappe-ui"; -import { ChevronsUpDown, Search } from "lucide-vue-next"; +import { ChevronsUpDown, Search } from "@lucide/vue"; import { computed, inject, ref } from "vue"; import CreateTeamDialog from "@/components/team/CreateTeamDialog.vue"; import TeamSwitcherItem from "@/components/team/TeamSwitcherItem.vue"; diff --git a/frontend/src/composables/useGroupedRows.ts b/frontend/src/composables/useGroupedRows.ts new file mode 100644 index 0000000..5f8b030 --- /dev/null +++ b/frontend/src/composables/useGroupedRows.ts @@ -0,0 +1,29 @@ +import { computed, type Ref } from "vue"; +import type { FormField } from "@/types/formfield"; + +export function useGroupedRows(fields: Ref<FormField[]>) { + return computed<FormField[][][]>(() => { + const rows = new Map<number, Map<number, FormField[]>>(); + for (const f of fields.value) { + const r = f.row_index ?? 0; + const c = f.column_index ?? 0; + if (!rows.has(r)) rows.set(r, new Map()); + const cols = rows.get(r)!; + if (!cols.has(c)) cols.set(c, []); + cols.get(c)!.push(f); + } + return [...rows.keys()] + .sort((a, b) => a - b) + .map((r) => { + const cols = rows.get(r)!; + return [...cols.keys()] + .sort((a, b) => a - b) + .map((c) => + cols + .get(c)! + .slice() + .sort((a, b) => (a.cell_index ?? 0) - (b.cell_index ?? 0)) + ); + }); + }); +} diff --git a/frontend/src/composables/useRouteData.ts b/frontend/src/composables/useRouteData.ts new file mode 100644 index 0000000..69aff34 --- /dev/null +++ b/frontend/src/composables/useRouteData.ts @@ -0,0 +1,12 @@ +import { computed } from "vue"; +import { useRouteData as useStore } from "@/stores/routeData"; + +export function useRouteData<T = unknown>() { + const store = useStore(); + return { + status: computed(() => store.state.status), + data: computed(() => store.state.data as T | undefined), + error: computed(() => store.state.error), + isNavigating: computed(() => store.isNavigating), + }; +} diff --git a/frontend/src/config/fieldTypes.ts b/frontend/src/config/fieldTypes.ts index 59a9a2c..a3da083 100644 --- a/frontend/src/config/fieldTypes.ts +++ b/frontend/src/config/fieldTypes.ts @@ -25,7 +25,6 @@ import { DateRangePicker, DateTimePicker, Password, - Rating, Select, Switch, Textarea, @@ -33,11 +32,36 @@ import { TimePicker, FormControl, } from "frappe-ui"; +import { + AlignLeft, + AtSign, + Calendar, + CalendarClock, + CalendarRange, + Clock, + Hash, + Heading1, + Heading2, + Heading3, + KeyRound, + Link2, + List, + ListChecks, + Paperclip, + Phone as PhoneIcon, + Pilcrow, + SquareCheck, + Star, + Table as TableIcon, + ToggleRight, + Type, +} from "@lucide/vue"; import Attachment from "@/components/fields/Attachment.vue"; import Heading from "@/components/fields/Heading.vue"; import Multiselect from "@/components/fields/multiselect/Multiselect.vue"; import MultiselectBuilderExtras from "@/components/fields/multiselect/MultiselectBuilderExtras.vue"; import Phone from "@/components/fields/Phone.vue"; +import Rating from "@/components/fields/Rating.vue"; import Table from "@/components/fields/Table.vue"; import { Fieldtype } from "@/types/FormsPro/form_field.types"; @@ -63,6 +87,8 @@ export type FieldTypeDefinition = { name: Fieldtype; /** Vue component used to render this field in input mode */ component: Component; + /** Lucide icon used in the Add Fields palette */ + icon: Component; /** Default props forwarded to the component */ props: Record<string, unknown>; /** How FieldRenderer lays out the label relative to the input */ @@ -87,6 +113,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.ATTACH, component: Attachment, + icon: Paperclip, props: { variant: "outline", filetypes: ["image/*", ".jpg", ".gif", ".pdf"], @@ -99,6 +126,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.DATA, component: FormControl, + icon: Type, props: { type: "text", variant: "outline" }, layout: "default", frappeFieldtype: "Data", @@ -108,6 +136,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.NUMBER, component: FormControl, + icon: Hash, props: { type: "number", variant: "outline" }, layout: "default", frappeFieldtype: "Int", @@ -117,6 +146,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.EMAIL, component: FormControl, + icon: AtSign, props: { type: "email", variant: "outline" }, layout: "default", frappeFieldtype: "Data", @@ -127,6 +157,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.DATE, component: DatePicker, + icon: Calendar, props: { variant: "outline", clearable: true, format: "D MMM YYYY" }, layout: "default", frappeFieldtype: "Date", @@ -136,6 +167,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.DATE_TIME, component: DateTimePicker, + icon: CalendarClock, props: { format: "DD MMM YYYY, hh:mm A", clearable: true, @@ -149,6 +181,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.DATE_RANGE, component: DateRangePicker, + icon: CalendarRange, props: { clearable: true, variant: "outline", format: "DD MMM 'YY" }, layout: "default", frappeFieldtype: "Data", @@ -158,6 +191,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.TIME_PICKER, component: TimePicker, + icon: Clock, props: { variant: "outline", use12Hour: true, clearable: true }, layout: "default", frappeFieldtype: "Time", @@ -167,6 +201,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.PASSWORD, component: Password, + icon: KeyRound, props: { variant: "outline" }, layout: "default", frappeFieldtype: "Password", @@ -176,6 +211,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.SELECT, component: Select, + icon: List, props: { variant: "outline" }, layout: "default", frappeFieldtype: "Select", @@ -185,6 +221,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.PHONE, component: Phone, + icon: PhoneIcon, props: { variant: "outline" }, layout: "default", frappeFieldtype: "Phone", @@ -194,6 +231,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.SWITCH, component: Switch, + icon: ToggleRight, props: {}, layout: "inline", frappeFieldtype: "Check", @@ -203,6 +241,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.TEXTAREA, component: Textarea, + icon: AlignLeft, props: { variant: "outline" }, layout: "default", frappeFieldtype: "Text", @@ -212,6 +251,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.TEXT_EDITOR, component: TextEditor, + icon: Pilcrow, props: { editorClass: "bg-surface-white w-full rounded-b form-description border rounded-b min-h-24", @@ -227,6 +267,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.LINK, component: Select, + icon: Link2, props: { variant: "outline" }, layout: "default", frappeFieldtype: "Link", @@ -236,6 +277,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.CHECKBOX, component: Checkbox, + icon: SquareCheck, props: {}, layout: "inline", frappeFieldtype: "Check", @@ -245,6 +287,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.RATING, component: Rating, + icon: Star, props: {}, layout: "default", frappeFieldtype: "Rating", @@ -254,6 +297,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.TABLE, component: Table, + icon: TableIcon, props: { options: { emptyState: { @@ -270,6 +314,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.MULTISELECT, component: Multiselect, + icon: ListChecks, props: {}, layout: "description-first", frappeFieldtype: "JSON", @@ -280,6 +325,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.HEADING_1, component: Heading, + icon: Heading1, props: {}, layout: "heading", frappeFieldtype: "HTML", @@ -289,6 +335,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.HEADING_2, component: Heading, + icon: Heading2, props: {}, layout: "heading", frappeFieldtype: "HTML", @@ -298,6 +345,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [ { name: Fieldtype.HEADING_3, component: Heading, + icon: Heading3, props: {}, layout: "heading", frappeFieldtype: "HTML", diff --git a/frontend/src/layouts/BaseLayout.vue b/frontend/src/layouts/BaseLayout.vue index e641511..0f9f8e9 100644 --- a/frontend/src/layouts/BaseLayout.vue +++ b/frontend/src/layouts/BaseLayout.vue @@ -55,7 +55,7 @@ <script setup lang="ts"> import { session } from "@/data/session"; import { Popover, Sidebar, type SidebarProps } from "frappe-ui"; -import { EllipsisVertical, LogOut } from "lucide-vue-next"; +import { EllipsisVertical, LogOut } from "@lucide/vue"; import type { PropType } from "vue"; import Avatar from "@/components/ui/Avatar.vue"; import TeamSwitcher from "@/components/team/TeamSwitcher.vue"; diff --git a/frontend/src/pages/EditForm.vue b/frontend/src/pages/EditForm.vue index ee7b7e9..f862f95 100644 --- a/frontend/src/pages/EditForm.vue +++ b/frontend/src/pages/EditForm.vue @@ -1,15 +1,24 @@ <template> - <FormBuilderLayout /> + <RouteError + v-if="status === 'error'" + :exc-type="error?.excType" + :http-status="error?.httpStatus" + :messages="error?.messages" + /> + <FormBuilderLayout v-else /> </template> <script setup lang="ts"> import FormBuilderLayout from "@/layouts/FormBuilderLayout.vue"; +import RouteError from "@/components/RouteError.vue"; import { useRoute } from "vue-router"; import { useEditForm } from "@/stores/editForm"; +import { useRouteData } from "@/composables/useRouteData"; import { watch } from "vue"; const route = useRoute(); const editFormStore = useEditForm(); +const { status, error } = useRouteData(); // Set the form ID when the route changes watch( diff --git a/frontend/src/pages/home/sidebarItems.ts b/frontend/src/pages/home/sidebarItems.ts index 291f1b1..7f714e5 100644 --- a/frontend/src/pages/home/sidebarItems.ts +++ b/frontend/src/pages/home/sidebarItems.ts @@ -1,4 +1,4 @@ -import { Files, UsersRound } from "lucide-vue-next"; +import { Files, UsersRound } from "@lucide/vue"; import { computed } from "vue"; import { useRoute } from "vue-router"; import type { SidebarProps } from "frappe-ui"; diff --git a/frontend/src/pages/manage/ManageForm.vue b/frontend/src/pages/manage/ManageForm.vue index ac8e1ed..b077328 100644 --- a/frontend/src/pages/manage/ManageForm.vue +++ b/frontend/src/pages/manage/ManageForm.vue @@ -1,13 +1,16 @@ <script setup lang="ts"> import BaseLayout from "@/layouts/BaseLayout.vue"; +import RouteError from "@/components/RouteError.vue"; import { watch } from "vue"; import { useManageForm } from "@/stores/form/manageForm"; +import { useRouteData } from "@/composables/useRouteData"; import { useRoute } from "vue-router"; import { useManageFormSidebarItems } from "./sidebarItems"; const manageFormStore = useManageForm(); const route = useRoute(); const sidebarItems = useManageFormSidebarItems(); +const { status, error } = useRouteData(); watch( () => route.params.id, @@ -19,7 +22,13 @@ watch( </script> <template> - <BaseLayout :sidebar-sections="sidebarItems"> + <RouteError + v-if="status === 'error'" + :exc-type="error?.excType" + :http-status="error?.httpStatus" + :messages="error?.messages" + /> + <BaseLayout v-else :sidebar-sections="sidebarItems"> <router-view /> </BaseLayout> </template> diff --git a/frontend/src/pages/manage/overview/Overview.vue b/frontend/src/pages/manage/overview/Overview.vue index 0e6049f..49b3769 100644 --- a/frontend/src/pages/manage/overview/Overview.vue +++ b/frontend/src/pages/manage/overview/Overview.vue @@ -2,9 +2,9 @@ import AccessSection from "@/components/form/manage/AccessSection.vue"; import DescriptionSection from "@/components/form/manage/DescriptionSection.vue"; import { useManageForm } from "@/stores/form/manageForm"; -import { FileText, CaseLower, Lock } from "lucide-vue-next"; +import { FileText, CaseLower, Lock } from "@lucide/vue"; import { formatPrettyDate } from "@/utils/date"; -import { TabButtons, LoadingText, Badge, Breadcrumbs } from "frappe-ui"; +import { TabButtons, Badge, Breadcrumbs } from "frappe-ui"; import Avatar from "@/components/ui/Avatar.vue"; import { useQueryParam } from "@/composables/useQueryParam"; import { computed } from "vue"; @@ -38,10 +38,7 @@ const selectedTab = useQueryParam<TabValue>("tab", "description", validTabValues <template> <div class="flex flex-col gap-4 w-full overflow-y-auto"> <Breadcrumbs :items="breadcrumbItems" /> - <div v-if="manageFormStore.formResource.value?.loading"> - <LoadingText /> - </div> - <div v-else-if="manageFormStore.formData" class="flex flex-col gap-3"> + <div v-if="manageFormStore.formData" class="flex flex-col gap-3"> <Badge v-if="manageFormStore.formData?.is_published" class="w-fit" diff --git a/frontend/src/pages/manage/sidebarItems.ts b/frontend/src/pages/manage/sidebarItems.ts index 7a98c6f..5ab56cd 100644 --- a/frontend/src/pages/manage/sidebarItems.ts +++ b/frontend/src/pages/manage/sidebarItems.ts @@ -1,4 +1,4 @@ -import { ArrowLeft, LayoutGrid, ListChecks } from "lucide-vue-next"; +import { ArrowLeft, LayoutGrid, ListChecks } from "@lucide/vue"; import { computed } from "vue"; import { useRoute, useRouter } from "vue-router"; import type { SidebarProps } from "frappe-ui"; diff --git a/frontend/src/pages/manage/submissions/Submissions.vue b/frontend/src/pages/manage/submissions/Submissions.vue index 7c9582e..002a428 100644 --- a/frontend/src/pages/manage/submissions/Submissions.vue +++ b/frontend/src/pages/manage/submissions/Submissions.vue @@ -1,6 +1,6 @@ <script setup lang="ts"> import { useManageForm } from "@/stores/form/manageForm"; -import { ListChecks } from "lucide-vue-next"; +import { ListChecks } from "@lucide/vue"; import { Breadcrumbs } from "frappe-ui"; import { computed } from "vue"; import SubmissionList from "@/components/form/submissions/SubmissionList.vue"; diff --git a/frontend/src/pages/submission/PublicEdit.vue b/frontend/src/pages/submission/PublicEdit.vue index f8305b4..7d34632 100644 --- a/frontend/src/pages/submission/PublicEdit.vue +++ b/frontend/src/pages/submission/PublicEdit.vue @@ -6,7 +6,7 @@ import { useSubmissionForm } from "@/stores/submissionForm"; import { useEditSubmission } from "@/stores/editSubmission"; import { computed, watch } from "vue"; import FormRenderer from "@/components/submission/FormRenderer.vue"; -import { CircleDashed } from "lucide-vue-next"; +import { CircleDashed } from "@lucide/vue"; import { formatDateTime } from "@/utils/date"; const route = useRoute(); diff --git a/frontend/src/pages/team/ManageTeam.vue b/frontend/src/pages/team/ManageTeam.vue index 53f226f..99d7fef 100644 --- a/frontend/src/pages/team/ManageTeam.vue +++ b/frontend/src/pages/team/ManageTeam.vue @@ -3,12 +3,21 @@ import { useTeam } from "@/stores/team"; import TeamMemberList from "@/components/team/TeamMemberList.vue"; import { Breadcrumbs, LoadingText } from "frappe-ui"; import ManageTeamHeader from "@/components/team/ManageTeamHeader.vue"; +import RouteError from "@/components/RouteError.vue"; +import { useRouteData } from "@/composables/useRouteData"; const teamStore = useTeam(); teamStore.initialize(); +const { status, error } = useRouteData(); </script> <template> - <div v-if="teamStore.teamMembersResource.loading"> + <RouteError + v-if="status === 'error'" + :exc-type="error?.excType" + :http-status="error?.httpStatus" + :messages="error?.messages" + /> + <div v-else-if="teamStore.teamMembersResource.loading"> <LoadingText text="Loading team info..." class="h-5" /> </div> <div v-else> diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 2808423..7c989de 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -1,7 +1,34 @@ import { userResource } from "@/data/user"; -import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; -import { session } from "./data/session"; +import { session } from "@/data/session"; +import { useRouteData } from "@/stores/routeData"; +import { useUser } from "@/stores/user"; import { isLoginRequired } from "@/utils/form"; +import { createResource } from "frappe-ui"; +import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; + +const formForViewResource = (id: string) => + createResource({ + url: "forms_pro.api.form.get_form_for_view", + params: { form_id: id }, + cache: ["FormView", id], + auto: false, + }); + +const formForEditResource = (id: string) => + createResource({ + url: "forms_pro.api.form.get_form_for_edit", + params: { form_id: id }, + cache: ["FormEdit", id], + auto: false, + }); + +const teamForManageResource = (teamId: string) => + createResource({ + url: "forms_pro.api.team.get_team_for_manage", + params: { team_id: teamId }, + cache: ["TeamManage", teamId], + auto: false, + }); const routes: RouteRecordRaw[] = [ { @@ -18,6 +45,12 @@ const routes: RouteRecordRaw[] = [ path: "team", name: "Manage Team", component: () => import("@/pages/team/ManageTeam.vue"), + meta: { + fetch: () => { + const user = useUser(); + return teamForManageResource(user.currentTeam?.name ?? ""); + }, + }, }, ], }, @@ -26,6 +59,9 @@ const routes: RouteRecordRaw[] = [ name: "Manage Form", component: () => import("@/pages/manage/ManageForm.vue"), redirect: { name: "Manage Form Overview" }, + meta: { + fetch: (route) => formForViewResource(route.params.id as string), + }, children: [ { path: "overview", @@ -43,6 +79,9 @@ const routes: RouteRecordRaw[] = [ path: "/edit-form/:id", name: "Edit Form", component: () => import("@/pages/EditForm.vue"), + meta: { + fetch: (route) => formForEditResource(route.params.id as string), + }, }, { path: "/p/:route(.*)", @@ -71,25 +110,27 @@ const router = createRouter({ routes, }); -router.beforeEach(async (to, _from, next) => { +router.beforeEach(async (to, _from) => { let isLoggedIn = session.isLoggedIn; try { await userResource.promise; - } catch (error) { + if (isLoggedIn) { + await useUser().initialize(); + } + } catch { isLoggedIn = false; } - if (to.name === "Login" && isLoggedIn) { - next({ name: "Home" }); - } else if ( - to.name !== "Login" && - !isLoggedIn && - to.meta.allowGuest !== true - ) { + if (to.name === "Login" && isLoggedIn) return { name: "Home" }; + if (to.meta.allowGuest) return true; + if (!isLoggedIn) { window.location.href = `/login?redirect-to=/forms${to.fullPath}`; - } else { - next(); + return false; } + + const store = useRouteData(); + await store.resolve(to); + return true; }); export default router; diff --git a/frontend/src/stores/editForm.ts b/frontend/src/stores/editForm.ts index 17c4e66..505fa9b 100644 --- a/frontend/src/stores/editForm.ts +++ b/frontend/src/stores/editForm.ts @@ -2,7 +2,7 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; import { createDocumentResource, createResource } from "frappe-ui"; import { mapDoctypeFieldForForm } from "@/utils/form_fields"; -import { FormField, FormFieldTypes } from "@/types/formfield"; +import { FormField, Fieldtype } from "@/types/formfield"; import { Form } from "@/types/form"; import { toast } from "vue-sonner"; import { dialog } from "@/utils/dialog"; @@ -21,6 +21,9 @@ export const useEditForm = defineStore("editForm", () => { const selectedField = ref<FormField | null>(null); const isUnsaved = computed(() => formResource.value?.isDirty || false); const isLoading = computed(() => formResource.value?.loading || false); + const isSaving = computed( + () => formResource.value?.setValue?.loading || false + ); const isPublished = computed( () => formResource.value?.doc?.is_published || false ); @@ -115,6 +118,11 @@ export const useEditForm = defineStore("editForm", () => { } async function save() { + if (!isUnsaved.value) { + toast.info("No changes to save"); + return; + } + if (!formResource.value) { toast.error("No form resource available"); return Promise.reject(new Error("No form resource available")); @@ -155,30 +163,29 @@ export const useEditForm = defineStore("editForm", () => { function saveAndPublish() { if (formResource.value) { formResource.value.doc.is_published = 1; - save(); + return save(); } } function togglePublish() { - if (formResource.value?.doc) { - formResource.value.setValue.submit( - { - is_published: !formResource.value.doc.is_published, + if (!formResource.value?.doc) return Promise.resolve(); + return formResource.value.setValue.submit( + { + is_published: !formResource.value.doc.is_published, + }, + { + onSuccess: () => { + if (formResource.value.doc.is_published) { + toast.success("Form published successfully"); + } else { + toast.info("Form unpublished successfully"); + } }, - { - onSuccess: () => { - if (formResource.value.doc.is_published) { - toast.success("Form published successfully"); - } else { - toast.info("Form unpublished successfully"); - } - }, - onError: () => { - toast.error("Failed to publish form"); - }, - } - ); - } + onError: () => { + toast.error("Failed to publish form"); + }, + } + ); } function updateFormData(data: Partial<Form>) { @@ -187,34 +194,165 @@ export const useEditForm = defineStore("editForm", () => { } } - function addField(fieldtype: string) { + function compact() { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!fs.length) return; + + // Remap row_index values to 0..N-1 (closes gaps left by deletions/moves) + const distinctRows = [...new Set(fs.map((f) => f.row_index ?? 0))].sort( + (a, b) => a - b + ); + const rowRemap = new Map(distinctRows.map((r, i) => [r, i])); + for (const f of fs) { + f.row_index = rowRemap.get(f.row_index ?? 0) ?? 0; + } + + // Remap distinct column_index values within each row to 0..M-1 + // (cells sharing a column_index stay grouped — multi-cell columns preserved) + const rowColsMap = new Map<number, Set<number>>(); + for (const f of fs) { + const r = f.row_index!; + if (!rowColsMap.has(r)) rowColsMap.set(r, new Set()); + rowColsMap.get(r)!.add(f.column_index ?? 0); + } + const colRemapByRow = new Map<number, Map<number, number>>(); + for (const [r, cols] of rowColsMap) { + const sorted = [...cols].sort((a, b) => a - b); + colRemapByRow.set(r, new Map(sorted.map((c, i) => [c, i]))); + } + for (const f of fs) { + f.column_index = + colRemapByRow.get(f.row_index!)!.get(f.column_index ?? 0) ?? 0; + } + + // Renumber cell_index within each (row, column) to 0..K-1 + const cellMap = new Map<string, FormField[]>(); + for (const f of fs) { + const key = `${f.row_index}-${f.column_index}`; + if (!cellMap.has(key)) cellMap.set(key, []); + cellMap.get(key)!.push(f); + } + for (const cells of cellMap.values()) { + cells + .sort((a, b) => (a.cell_index ?? 0) - (b.cell_index ?? 0)) + .forEach((f, i) => { + f.cell_index = i; + }); + } + } + + function lastRowIndex(fs: FormField[]): number { + return fs.reduce((m, f) => Math.max(m, f.row_index ?? 0), -1); + } + + function addField(fieldtype: Fieldtype) { if (formResource.value?.doc) { + const fs: FormField[] = formResource.value.doc.fields; + const newField: FormField = { - idx: formResource.value.doc.fields.length + 1, - fieldtype: fieldtype as FormFieldTypes, + idx: fs.length + 1, + fieldtype, label: "", fieldname: "", options: "", default: "", description: "", + row_index: lastRowIndex(fs) + 1, + column_index: 0, + cell_index: 0, }; - formResource.value.doc.fields.push(newField); + fs.push(newField); } } function addFieldFromDoctype(field: any) { + if (!formResource.value?.doc) return; + const fs: FormField[] = formResource.value.doc.fields; + const _newField: FormField = { - idx: formResource.value.doc.fields.length + 1, + idx: fs.length + 1, fieldtype: field.fieldtype, label: field.label, fieldname: field.fieldname, options: field.options, default: field.default, description: field.description, + row_index: lastRowIndex(fs) + 1, + column_index: 0, + cell_index: 0, }; - formResource.value.doc.fields.push(_newField); + fs.push(_newField); + } + + function moveField(field: FormField, targetRow: number, targetCol: number) { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!fs.includes(field)) return; + + // Shift existing columns in target row to open a slot (whole columns shift, + // multi-cell columns stay grouped because all their cells share column_index) + for (const f of fs) { + if ( + f !== field && + (f.row_index ?? 0) === targetRow && + (f.column_index ?? 0) >= targetCol + ) { + f.column_index = (f.column_index ?? 0) + 1; + } + } + + field.row_index = targetRow; + field.column_index = targetCol; + field.cell_index = 0; + + compact(); + } + + function insertCell( + field: FormField, + targetRow: number, + targetCol: number, + atCell: number + ) { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!fs.includes(field)) return; + + // Shift cells at or below atCell within (targetRow, targetCol) down by 1 + for (const f of fs) { + if ( + f !== field && + (f.row_index ?? 0) === targetRow && + (f.column_index ?? 0) === targetCol && + (f.cell_index ?? 0) >= atCell + ) { + f.cell_index = (f.cell_index ?? 0) + 1; + } + } + + field.row_index = targetRow; + field.column_index = targetCol; + field.cell_index = atCell; + + compact(); + } + + function insertNewRow(field: FormField, atRow: number) { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!fs.includes(field)) return; + + // Push all rows at or below atRow down by 1 + for (const f of fs) { + if (f !== field && (f.row_index ?? 0) >= atRow) { + f.row_index = (f.row_index ?? 0) + 1; + } + } + + field.row_index = atRow; + field.column_index = 0; + field.cell_index = 0; + + compact(); } function removeField(field: FormField) { @@ -222,6 +360,7 @@ export const useEditForm = defineStore("editForm", () => { formResource.value.doc.fields = formResource.value.doc.fields.filter( (f: FormField) => f !== field ); + compact(); } } @@ -249,6 +388,7 @@ export const useEditForm = defineStore("editForm", () => { // Computed originalFormData, isLoading, + isSaving, isError, formData, fields, @@ -269,5 +409,8 @@ export const useEditForm = defineStore("editForm", () => { selectField, updateField, removeField, + moveField, + insertCell, + insertNewRow, }; }); diff --git a/frontend/src/stores/form/manageForm.ts b/frontend/src/stores/form/manageForm.ts index 501ac0f..5411d27 100644 --- a/frontend/src/stores/form/manageForm.ts +++ b/frontend/src/stores/form/manageForm.ts @@ -1,9 +1,10 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; -import { createResource, useDoc } from "frappe-ui"; +import { createResource } from "frappe-ui"; import { toast } from "vue-sonner"; // @ts-ignore import { sessionUser } from "@/data/session"; +import { useRouteData as useRouteDataStore } from "@/stores/routeData"; export type PermissionTypes = "read" | "write" | "share" | "submit"; @@ -25,11 +26,15 @@ export type SharedAccessUser = { }; export const useManageForm = defineStore("manageForm", () => { + const routeData = useRouteDataStore(); const currentFormId = ref<string>(""); - const formData = computed(() => formResource.value?.doc || null); - const formFields = computed(() => formResource.value?.doc?.fields || []); - const formResource = ref<any>(null); - const formOwner = computed(() => formResource.value?.doc?.owner || null); + + // Primary form document is now resolved by the router guard via + // get_form_for_view and surfaced through the routeData store. This + // store no longer fetches the Form doc itself. + const formData = computed<any>(() => routeData.state.data as any); + const formFields = computed(() => formData.value?.fields || []); + const formOwner = computed(() => formData.value?.owner || null); const sharedAccessUsers = computed<SharedAccessUser[]>( () => formAccessResource.data || [] ); @@ -54,10 +59,6 @@ export const useManageForm = defineStore("manageForm", () => { async function initialize(formId: string) { currentFormId.value = formId; - formResource.value = useDoc({ - doctype: "Form", - name: formId, - }); formAccessResource.fetch(); } @@ -162,7 +163,6 @@ export const useManageForm = defineStore("manageForm", () => { currentFormId, formData, formFields, - formResource, formAccessResource, }; }); diff --git a/frontend/src/stores/routeData.ts b/frontend/src/stores/routeData.ts new file mode 100644 index 0000000..17fbbe1 --- /dev/null +++ b/frontend/src/stores/routeData.ts @@ -0,0 +1,58 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { RouteLocationNormalized } from "vue-router"; + +type Status = "idle" | "loading" | "ok" | "error"; + +interface RouteError { + excType?: string; + httpStatus?: number; + messages: string[]; +} + +interface RouteState { + status: Status; + data: unknown; + error: RouteError | null; +} + +export const useRouteData = defineStore("routeData", () => { + const state = ref<RouteState>({ status: "idle", data: null, error: null }); + const isNavigating = ref(false); + + async function resolve(route: RouteLocationNormalized): Promise<void> { + const fetchFn = route.meta.fetch; + state.value = { status: "loading", data: null, error: null }; + isNavigating.value = true; + + try { + if (!fetchFn) { + state.value = { status: "ok", data: null, error: null }; + return; + } + const resource = fetchFn(route); + const data = await resource.fetch(); + state.value = { status: "ok", data, error: null }; + } catch (err: any) { + state.value = { + status: "error", + data: null, + error: { + excType: err?.exc_type, + httpStatus: err?.response?.status, + messages: err?.messages?.length + ? err.messages + : [err?.message ?? "Something went wrong"], + }, + }; + } finally { + isNavigating.value = false; + } + } + + function clear(): void { + state.value = { status: "idle", data: null, error: null }; + } + + return { state, isNavigating, resolve, clear }; +}); diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index a039baf..3e213f3 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -40,9 +40,14 @@ export const useUser = defineStore("user", () => { }, }); - async function initialize() { - await userResource.fetch(); - await userTeamsResource.fetch(); + let initPromise: Promise<void> | null = null; + async function initialize(): Promise<void> { + if (initPromise) return initPromise; + initPromise = (async () => { + await userResource.fetch(); + await userTeamsResource.fetch(); + })(); + return initPromise; } function fetchUser() { @@ -53,11 +58,6 @@ export const useUser = defineStore("user", () => { userTeamsResource.fetch(); } - // @ts-ignore - function getCurrentTeamFromAllTeams() { - return userTeams.value?.find((team) => team.is_current); - } - function setCurrentTeam(team: UserTeam) { currentTeam.value = team; } diff --git a/frontend/src/types/FormsPro/form.types.ts b/frontend/src/types/FormsPro/form.types.ts new file mode 100644 index 0000000..1293ece --- /dev/null +++ b/frontend/src/types/FormsPro/form.types.ts @@ -0,0 +1,38 @@ +import { FormField } from "./form_field.types"; + +export interface Form { + name: string; + creation: string; + modified: string; + owner: string; + modified_by: string; + docstatus: 0 | 1 | 2; + parent?: string; + parentfield?: string; + parenttype?: string; + idx?: number; + /** Is Published? : Check */ + is_published?: 0 | 1; + /** Route : Data */ + route?: string; + /** Title : Data */ + title: string; + /** Linked Doctype : Link - DocType */ + linked_doctype: string; + /** Linked Team : Link - FP Team */ + linked_team_id: string; + /** Login Required : Check */ + login_required?: 0 | 1; + /** Allow Incomplete Forms : Check - Allow saving Draft forms */ + allow_incomplete?: 0 | 1; + /** Success Title : Data */ + success_title?: string; + /** Success Description : Text Editor */ + success_description?: string; + /** Description : Text Editor */ + description?: string; + /** Fields : Table - Form Field */ + fields?: FormField[]; + /** Meta Data : Code */ + metadata?: string; +} diff --git a/frontend/src/types/FormsPro/form_field.types.ts b/frontend/src/types/FormsPro/form_field.types.ts index 288e265..6c569e3 100644 --- a/frontend/src/types/FormsPro/form_field.types.ts +++ b/frontend/src/types/FormsPro/form_field.types.ts @@ -34,6 +34,12 @@ export interface FormField { parentfield?: string; parenttype?: string; idx?: number; + /** Row Index : Int */ + row_index?: number; + /** Column Index : Int */ + column_index?: number; + /** Cell Index : Int */ + cell_index?: number; /** Mandatory : Check */ reqd?: 0 | 1; /** Hidden : Check */ diff --git a/frontend/src/types/FormsPro/fpteam.types.ts b/frontend/src/types/FormsPro/fpteam.types.ts new file mode 100644 index 0000000..faa490d --- /dev/null +++ b/frontend/src/types/FormsPro/fpteam.types.ts @@ -0,0 +1,20 @@ +import { FPTeamMember } from "./fpteam_member.types"; + +export interface FPTeam { + name: string; + creation: string; + modified: string; + owner: string; + modified_by: string; + docstatus: 0 | 1 | 2; + parent?: string; + parentfield?: string; + parenttype?: string; + idx?: number; + /** Logo : Attach Image */ + logo?: string; + /** Team Name : Data */ + team_name: string; + /** Users : Table MultiSelect - FP Team Member */ + users?: FPTeamMember[]; +} diff --git a/frontend/src/types/FormsPro/fpteam_member.types.ts b/frontend/src/types/FormsPro/fpteam_member.types.ts new file mode 100644 index 0000000..1fe67e6 --- /dev/null +++ b/frontend/src/types/FormsPro/fpteam_member.types.ts @@ -0,0 +1,14 @@ +export interface FPTeamMember { + name: string; + creation: string; + modified: string; + owner: string; + modified_by: string; + docstatus: 0 | 1 | 2; + parent?: string; + parentfield?: string; + parenttype?: string; + idx?: number; + /** User : Link - User */ + user?: string; +} diff --git a/frontend/src/types/formfield.ts b/frontend/src/types/formfield.ts index 5480c66..62c0547 100644 --- a/frontend/src/types/formfield.ts +++ b/frontend/src/types/formfield.ts @@ -1,29 +1,10 @@ -export enum FormFieldTypes { - Attach = "Attach", - Data = "Data", - Number = "Number", - Email = "Email", - Date = "Date", - DateTime = "Date Time", - DateRange = "Date Range", - TimePicker = "Time Picker", - Password = "Password", - Select = "Select", - Phone = "Phone", - Switch = "Switch", - Textarea = "Textarea", - TextEditor = "Text Editor", - Link = "Link", - Checkbox = "Checkbox", - Rating = "Rating", - Table = "Table", - Multiselect = "Multiselect", -} +export { Fieldtype } from "@/types/FormsPro/form_field.types"; +import { Fieldtype } from "@/types/FormsPro/form_field.types"; export type FormField = { label: string; fieldname: string; - fieldtype: FormFieldTypes; + fieldtype: Fieldtype; description?: string; reqd?: boolean; hidden?: boolean; @@ -31,4 +12,7 @@ export type FormField = { default?: string; idx?: number; conditional_logic?: string; + row_index?: number; + column_index?: number; + cell_index?: number; }; diff --git a/frontend/src/types/router.d.ts b/frontend/src/types/router.d.ts new file mode 100644 index 0000000..8e3b0b0 --- /dev/null +++ b/frontend/src/types/router.d.ts @@ -0,0 +1,13 @@ +import "vue-router"; +import type { RouteLocationNormalized } from "vue-router"; + +declare module "vue-router" { + interface RouteMeta { + allowGuest?: boolean; + fetch?: (route: RouteLocationNormalized) => { + fetch: () => Promise<unknown>; + }; + } +} + +export {}; diff --git a/frontend/src/utils/conditionals.ts b/frontend/src/utils/conditionals.ts index 8fae4f0..28f4bd4 100644 --- a/frontend/src/utils/conditionals.ts +++ b/frontend/src/utils/conditionals.ts @@ -4,7 +4,8 @@ import { Condition, Actions, } from "@/types/conditional-render.types"; -import { FormField } from "@/types/formfield"; +import { FormField, Fieldtype } from "@/types/formfield"; +import { getFieldTypeDef } from "@/config/fieldTypes"; /** * Parse conditional_logic string into ConditionalLogic object @@ -35,13 +36,14 @@ function getFieldValue( return null; } - // Handle boolean/switch fields - if (fieldType === "Switch" || fieldType === "Checkbox") { + const typeDef = getFieldTypeDef(fieldType as Fieldtype); + + if (typeDef?.isBoolean) { return Boolean(fieldValue); } // Handle number fields - if (fieldType === "Number") { + if (fieldType === Fieldtype.NUMBER) { const num = Number(fieldValue); return isNaN(num) ? null : num; } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 3274555..62354d9 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -75,60 +75,6 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== -"@biomejs/biome@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-1.9.4.tgz#89766281cbc3a0aae865a7ff13d6aaffea2842bf" - integrity sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog== - optionalDependencies: - "@biomejs/cli-darwin-arm64" "1.9.4" - "@biomejs/cli-darwin-x64" "1.9.4" - "@biomejs/cli-linux-arm64" "1.9.4" - "@biomejs/cli-linux-arm64-musl" "1.9.4" - "@biomejs/cli-linux-x64" "1.9.4" - "@biomejs/cli-linux-x64-musl" "1.9.4" - "@biomejs/cli-win32-arm64" "1.9.4" - "@biomejs/cli-win32-x64" "1.9.4" - -"@biomejs/cli-darwin-arm64@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz#dfa376d23a54a2d8f17133c92f23c1bf2e62509f" - integrity sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw== - -"@biomejs/cli-darwin-x64@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz#eafc2ce3849d385fc02238aad1ca4a73395a64d9" - integrity sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg== - -"@biomejs/cli-linux-arm64-musl@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz#d780c3e01758fc90f3268357e3f19163d1f84fca" - integrity sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA== - -"@biomejs/cli-linux-arm64@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz#8ed1dd0e89419a4b66a47f95aefb8c46ae6041c9" - integrity sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g== - -"@biomejs/cli-linux-x64-musl@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz#f36982b966bd671a36671e1de4417963d7db15fb" - integrity sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg== - -"@biomejs/cli-linux-x64@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz#a0a7f56680c76b8034ddc149dbf398bdd3a462e8" - integrity sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg== - -"@biomejs/cli-win32-arm64@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz#e2ef4e0084e76b7e26f0fc887c5ef1265ea56200" - integrity sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg== - -"@biomejs/cli-win32-x64@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz#4c7afa90e3970213599b4095e62f87e5972b2340" - integrity sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA== - "@bramus/specificity@^2.4.2": version "2.4.2" resolved "https://registry.yarnpkg.com/@bramus/specificity/-/specificity-2.4.2.tgz#aa8db8eb173fdee7324f82284833106adeecc648" @@ -344,17 +290,22 @@ resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== -"@lottiefiles/dotlottie-vue@^0.11.11": - version "0.11.11" - resolved "https://registry.yarnpkg.com/@lottiefiles/dotlottie-vue/-/dotlottie-vue-0.11.11.tgz#cd3e80e32f9d698f5d7853240e807929aa5bdeda" - integrity sha512-rYjN1PdDVyy8iMbKIqBTKNfg7DoFu/MrG9ARuSjqGIf/daNEIpygOVwfrcN21TFatDvDc7MxFvWu8OBTzGVwNw== +"@lottiefiles/dotlottie-vue@^0.11.12": + version "0.11.12" + resolved "https://registry.yarnpkg.com/@lottiefiles/dotlottie-vue/-/dotlottie-vue-0.11.12.tgz#0bc0e4e411664658eb00fb68fb8666a23cefe174" + integrity sha512-P40XlAEUJSi2/Suc+xzWzIQwW7nryTC+IRd4+eaWl7WlIKovw1w0n/xOBOnn2tAns51dqwik4mMmxeD9ANYPlg== dependencies: - "@lottiefiles/dotlottie-web" "0.71.0" + "@lottiefiles/dotlottie-web" "0.72.0" -"@lottiefiles/dotlottie-web@0.71.0": - version "0.71.0" - resolved "https://registry.yarnpkg.com/@lottiefiles/dotlottie-web/-/dotlottie-web-0.71.0.tgz#bf066bd968459d78644bc9385ba776d48b4a7b61" - integrity sha512-CliQttL0Rk0or/aDQkqUJXnC9Cm5TxzNdOqy/YlzKlU6mu+XgTMwyt5/HpPiltlMeaqBmM9mOzfevpyP4QmudQ== +"@lottiefiles/dotlottie-web@0.72.0": + version "0.72.0" + resolved "https://registry.yarnpkg.com/@lottiefiles/dotlottie-web/-/dotlottie-web-0.72.0.tgz#10224106babac004933a1013bb5c97db7e69ff6f" + integrity sha512-CllhCIWWcCrUN71RCwyj9vbSQGE8H465m2Efudizfe5CIitQly7TO7DS2f6juBr5VZVy/r5AqQPUcbvz2uoxtQ== + +"@lucide/vue@^1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@lucide/vue/-/vue-1.11.0.tgz#8d3f749bf93cc13b5aa6e21205e1e60d0bfa0231" + integrity sha512-ISNBw5Xm7cwfwMQ/sEYelzBCpBnpIcAJUUfIBV62Q6fL10WIEZBrVho1tjragziFTVImDupJYzWozxRn55Fc1g== "@napi-rs/wasm-runtime@^1.1.3": version "1.1.3" @@ -389,17 +340,112 @@ resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.124.0.tgz#1dfd7b3fbb98febc2f91b505f48c940db73c8701" integrity sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg== +"@oxlint/binding-android-arm-eabi@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.64.0.tgz#e6870e351a78f4bb74db091006af12cc43dad184" + integrity sha512-2r6Nq3XXGLHEXKkSj8JtmJ6N4gDw431DPFOg0ZoJHlNjnG6HVMm/ksQ10m0HJ8WBvwgMe1L50UHPaYZutCRPCw== + +"@oxlint/binding-android-arm64@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.64.0.tgz#6621b6da00951125371087d8aee37cf9231ebf5c" + integrity sha512-ePJMpePgg7fBv+L/hVx1xXRU5/5gd5m0obLA6hPEfLXF3GjpR8idIDbY1dhQYhyz1ms2wdTccSboo6KEd2Oxtg== + +"@oxlint/binding-darwin-arm64@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.64.0.tgz#2a2a23bf1026f2dae0c2e1a722b3af2a4f7a77ad" + integrity sha512-U4DMLQd10gJLuoSTLSGbfv3bGjTlUNsScm9Dgb8wwBqmCzidf1pE1pXV4doGNxqwH3KtVng1AGTINA0NvkGLvQ== + +"@oxlint/binding-darwin-x64@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.64.0.tgz#8ce45147f30b9d30f304da4c70b5a73ec38cbeee" + integrity sha512-GoRIL48QWm4/TAvjN8pB1nAG+1/uqc9EdnWT9zqHeb6wsmjZtywj8VRe5aGW47Fdb64YtLOsdLqVxOvQuz98Wg== + +"@oxlint/binding-freebsd-x64@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.64.0.tgz#5d191855a3e18787ed530e0e6ff9b64f96aa06e4" + integrity sha512-5dFkv4tkg7PxJJGS9/OjrJwjhuHczrd3OQOkRE0wHcLM+ncUnULtzEPWjqGOxTXxZnLWcB91bGiIznx89TVXyQ== + +"@oxlint/binding-linux-arm-gnueabihf@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.64.0.tgz#fe9f677cdc2ddcacba6dde6fcd9ada19e0d192a8" + integrity sha512-jsBqMLl/uOL5+Kq/+BtK9FrmiNGUbx8SiyZXv+WlUxA45KuwcLu9BfiSIL3I3DBDgWM3yZizDITnTK9BcqNBQg== + +"@oxlint/binding-linux-arm-musleabihf@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.64.0.tgz#7e52f92326263da23dd3d2d6df512d73237cd44f" + integrity sha512-1lrj8At/Uuc9GhjrVFBQo0NEjfBrTkzpmtHIGAhNnIXqn1CAyGL+qrztUsXb2GIluJrpl9Q7qRLJOb/NqydacQ== + +"@oxlint/binding-linux-arm64-gnu@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.64.0.tgz#b5f779476eecfaa1d1a9c87955209d1a35b38ff6" + integrity sha512-HpSQbubwh03mMhAdy2BYtad/fsY8vDFHDAb6bUwuCYg2VD3xCQgn6ArKcO0oZyLCheacKTv4PrF3Mfu5hgoE2g== + +"@oxlint/binding-linux-arm64-musl@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.64.0.tgz#32d0c76ac16287603244c8eacec87a5bfd7d88b6" + integrity sha512-00QQ0h0Y7u0G69BgiH3+ky2aaq/QvkDL6DYok8htIuJHxybiux5aQ8jwmg8qIk9wha6UagUP2BAwAzbemcJbpg== + +"@oxlint/binding-linux-ppc64-gnu@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.64.0.tgz#149d69fa84963dd4517398ac379d9403b1d09dc5" + integrity sha512-2GaimTV6EMW+s5HS0An3oGbQme3BgHswvfVdGk3EB57Xe9+/gyT+Qd7lNVzb3rtir52vbIPzXfaYArzs5b5zcw== + +"@oxlint/binding-linux-riscv64-gnu@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.64.0.tgz#295737500bab998b01daeed51b5d09cd35875259" + integrity sha512-H46AtFb9wypjoVwGdlxrm0DsD809NGmtiK9HiyPKTxkSte2YjhC4S+00rOIrwCaxcyPiGid3Y3OMXp5KMAkGZw== + +"@oxlint/binding-linux-riscv64-musl@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.64.0.tgz#63652fc657acfbe8e1466e96b9b64e0ce86e92ec" + integrity sha512-HEgsidjjvvyzdg82icYkuFCf7REDV7B9JFwbIMbVwrKLBY0MrXX+bku3POn/hduZ2yW91IyVDUMq0Bf02KwXQw== + +"@oxlint/binding-linux-s390x-gnu@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.64.0.tgz#c6b3e3a435a363ba508f61457c205dd9e396b1de" + integrity sha512-Axvm8qryotmKN00P5w4JapaSjvP2LOSbdbBJiX+2SuHd3QzhW7TUc8skqgw+ahQZ5DmzEYeHCqauvW8f32Ns6Q== + +"@oxlint/binding-linux-x64-gnu@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.64.0.tgz#13774484e049529fdeafa99f5ce5741c3d96efee" + integrity sha512-cR60vSd7+m+KRZ3GQGfDxWwahW5RMXg0qlGvAluZr0fTUYvw0H9N9AXAF/M/PMqgytyqvVNmBAkJG9l7U30Y1g== + +"@oxlint/binding-linux-x64-musl@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.64.0.tgz#cf134649a30cd6fcef77cfc924a3c0d4959ade16" + integrity sha512-2u/aPZ9pEg7HnvZPDsHxUGNnrpr4qaHi+mCgLgpt+LYRzPrS4Px4wPfkIdRdr2GvKnaYyt+XSlto0Vm5sbStTg== + +"@oxlint/binding-openharmony-arm64@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.64.0.tgz#a6559f9e2aea34a15f12910e01faad189fb5cebb" + integrity sha512-kfhkGfCdoXLSxEkrhDlJrvBYajGmq+ma4EMc53dsOWTq+rIBOlI0vTBmpZNnM5oH2LY/K/w1HAK+UQEgjgpVUg== + +"@oxlint/binding-win32-arm64-msvc@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.64.0.tgz#e5aa13719cb0149a51f5e68346f68bec74cd4d9f" + integrity sha512-r/cNKBFieONoVu2bb1KkVouq9W+edDUgHumXJGphCRRj+U0xaD4nanrw8ZOqo0IsutPkEM4vCcGBpak6x5aXMg== + +"@oxlint/binding-win32-ia32-msvc@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.64.0.tgz#d4c021f8591cdd7d7d1d4c9f1e8554f296fa3206" + integrity sha512-tUw0xUUwEFVZbpJoeCblkv8SJA4Xz3CdXCJbAnBsiNLyxDrk2tLcxEAS6M73Q7hHHDg3OtwI8vZVK3t5RJt4Gw== + +"@oxlint/binding-win32-x64-msvc@1.64.0": + version "1.64.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.64.0.tgz#c0822ce3700a2d21b1d82cb56e3f506ea33a2a4c" + integrity sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@playwright/test@^1.59.1": - version "1.59.1" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6" - integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg== +"@playwright/test@^1.60.0": + version "1.60.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.60.0.tgz#e696c31427e8882851235cd556dc2490c3206d97" + integrity sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag== dependencies: - playwright "1.59.1" + playwright "1.60.0" "@popperjs/core@^2.11.2", "@popperjs/core@^2.9.0": version "2.11.8" @@ -862,12 +908,12 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== -"@types/node@^25.6.0": - version "25.6.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" - integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== +"@types/node@^25.7.0": + version "25.7.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.7.0.tgz#7498f82e90dbdce7c34b75aaaa256c498a0ebe6c" + integrity sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg== dependencies: - undici-types "~7.19.0" + undici-types "~7.21.0" "@types/trusted-types@^2.0.7": version "2.0.7" @@ -986,24 +1032,24 @@ convert-source-map "^2.0.0" tinyrainbow "^3.1.0" -"@volar/language-core@2.4.27": - version "2.4.27" - resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.27.tgz#c66d44cd22a914384d238bbcd0f621ecc57e3618" - integrity sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ== +"@volar/language-core@2.4.28": + version "2.4.28" + resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.28.tgz#c21f365a91c1dffe8bd7264fd491770c8d74fef3" + integrity sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ== dependencies: - "@volar/source-map" "2.4.27" + "@volar/source-map" "2.4.28" -"@volar/source-map@2.4.27": - version "2.4.27" - resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.27.tgz#8ce6f16e207987078fd866e2faf65c35c4d15987" - integrity sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg== +"@volar/source-map@2.4.28": + version "2.4.28" + resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.28.tgz#b40254e8c96199e5f1e0796777c593c617ad270e" + integrity sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ== -"@volar/typescript@2.4.27": - version "2.4.27" - resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.27.tgz#8950318a33d5dfcdc4b0e5bbe5a38c1b8383eae6" - integrity sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg== +"@volar/typescript@2.4.28": + version "2.4.28" + resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.28.tgz#83f86356e84eb101b8081a44c104f2f2ced8411f" + integrity sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw== dependencies: - "@volar/language-core" "2.4.27" + "@volar/language-core" "2.4.28" path-browserify "^1.0.1" vscode-uri "^3.0.8" @@ -1054,19 +1100,19 @@ resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== -"@vue/devtools-api@^7.7.2": - version "7.7.7" - resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.7.tgz#5ef5f55f60396220725a273548c0d7ee983d5d34" - integrity sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg== +"@vue/devtools-api@^7.7.7": + version "7.7.9" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz#999dbea50da6b00cf59a1336f11fdc2b43d9e063" + integrity sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g== dependencies: - "@vue/devtools-kit" "^7.7.7" + "@vue/devtools-kit" "^7.7.9" -"@vue/devtools-kit@^7.7.7": - version "7.7.7" - resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz#41a64f9526e9363331c72405544df020ce2e3641" - integrity sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA== +"@vue/devtools-kit@^7.7.9": + version "7.7.9" + resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz#bc218a815616e8987df7ab3e10fc1fb3b8706c58" + integrity sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA== dependencies: - "@vue/devtools-shared" "^7.7.7" + "@vue/devtools-shared" "^7.7.9" birpc "^2.3.0" hookable "^5.5.3" mitt "^3.0.1" @@ -1074,25 +1120,25 @@ speakingurl "^14.0.1" superjson "^2.2.2" -"@vue/devtools-shared@^7.7.7": - version "7.7.7" - resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz#ff14aa8c1262ebac8c0397d3b09f767cd489750c" - integrity sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw== +"@vue/devtools-shared@^7.7.9": + version "7.7.9" + resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz#fa4c096b744927081a7dda5fcf05f34b1ae6ca14" + integrity sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA== dependencies: rfdc "^1.4.1" -"@vue/language-core@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.2.4.tgz#03bb7a67ab8639fabb2cc4e49360fc742e99816a" - integrity sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew== +"@vue/language-core@3.2.8": + version "3.2.8" + resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.2.8.tgz#3bd38c343b89976208b7996bc670df56313047de" + integrity sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g== dependencies: - "@volar/language-core" "2.4.27" + "@volar/language-core" "2.4.28" "@vue/compiler-dom" "^3.5.0" "@vue/shared" "^3.5.0" - alien-signals "^3.0.0" + alien-signals "^3.1.2" muggle-string "^0.4.1" path-browserify "^1.0.1" - picomatch "^4.0.2" + picomatch "^4.0.4" "@vue/reactivity@3.5.33": version "3.5.33" @@ -1152,14 +1198,14 @@ "@vueuse/shared" "12.8.2" vue "^3.5.13" -"@vueuse/core@^13.9.0": - version "13.9.0" - resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.9.0.tgz#051aeff47a259e9e4d7d0cc3e54879817b0cbcad" - integrity sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA== +"@vueuse/core@^14.3.0": + version "14.3.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-14.3.0.tgz#66c92f9ad7ee989e4a8fd23ec368e55429ea42b2" + integrity sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw== dependencies: "@types/web-bluetooth" "^0.0.21" - "@vueuse/metadata" "13.9.0" - "@vueuse/shared" "13.9.0" + "@vueuse/metadata" "14.3.0" + "@vueuse/shared" "14.3.0" "@vueuse/metadata@10.11.1": version "10.11.1" @@ -1171,10 +1217,10 @@ resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3" integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A== -"@vueuse/metadata@13.9.0": - version "13.9.0" - resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-13.9.0.tgz#57c738d99661c33347080c0bc4cd11160e0d0881" - integrity sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg== +"@vueuse/metadata@14.3.0": + version "14.3.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-14.3.0.tgz#d782b00732d1568503027965c0be92bc1699d76c" + integrity sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw== "@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0": version "10.11.1" @@ -1190,17 +1236,17 @@ dependencies: vue "^3.5.13" -"@vueuse/shared@13.9.0": - version "13.9.0" - resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.9.0.tgz#7168b4ed647e625b05eb4e7e80fe8aabd00e3923" - integrity sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g== +"@vueuse/shared@14.3.0": + version "14.3.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-14.3.0.tgz#a3e7e6391f9ed7f363cbb28c32c4a278efaacbd0" + integrity sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg== acorn@^8.14.1, acorn@^8.15.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== -alien-signals@^3.0.0: +alien-signals@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-3.1.2.tgz#26e623e3ed81e401df1a7c503f726e2288a4fa02" integrity sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw== @@ -1271,15 +1317,14 @@ ast-v8-to-istanbul@^1.0.0: estree-walker "^3.0.3" js-tokens "^10.0.0" -autoprefixer@^10.4.2: - version "10.4.21" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.21.tgz#77189468e7a8ad1d9a37fbc08efc9f480cf0a95d" - integrity sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ== +autoprefixer@^10.5.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.5.0.tgz#33d87e443430f020a0f85319d6ff1593cb291be9" + integrity sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong== dependencies: - browserslist "^4.24.4" - caniuse-lite "^1.0.30001702" - fraction.js "^4.3.7" - normalize-range "^0.1.2" + browserslist "^4.28.2" + caniuse-lite "^1.0.30001787" + fraction.js "^5.3.4" picocolors "^1.1.1" postcss-value-parser "^4.2.0" @@ -1293,6 +1338,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +baseline-browser-mapping@^2.10.12: + version "2.10.27" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3" + integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA== + bidi-js@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2" @@ -1333,15 +1383,16 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.24.4: - version "4.25.4" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.4.tgz#ebdd0e1d1cf3911834bab3a6cd7b917d9babf5af" - integrity sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg== +browserslist@^4.28.2: + version "4.28.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" + integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== dependencies: - caniuse-lite "^1.0.30001737" - electron-to-chromium "^1.5.211" - node-releases "^2.0.19" - update-browserslist-db "^1.1.3" + baseline-browser-mapping "^2.10.12" + caniuse-lite "^1.0.30001782" + electron-to-chromium "^1.5.328" + node-releases "^2.0.36" + update-browserslist-db "^1.2.3" buffer@^5.5.0: version "5.7.1" @@ -1356,10 +1407,10 @@ camelcase-css@^2.0.1: resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== -caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001737: - version "1.0.30001741" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz#67fb92953edc536442f3c9da74320774aa523143" - integrity sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw== +caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001787: + version "1.0.30001791" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz#dfb93d85c40ad380c57123e72e10f3c575786b51" + integrity sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ== chai@^6.2.2: version "6.2.2" @@ -1588,10 +1639,10 @@ echarts@^5.6.0: tslib "2.3.0" zrender "5.6.1" -electron-to-chromium@^1.5.211: - version "1.5.215" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz#200c8d69b1270af6126837b6b1f95077c3a347b1" - integrity sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ== +electron-to-chromium@^1.5.328: + version "1.5.349" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz#9b9c6a6d84d1107557c18a9336099ce0ee890e5b" + integrity sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A== emoji-regex@^8.0.0: version "8.0.0" @@ -1727,10 +1778,10 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -fraction.js@^4.3.7: - version "4.3.7" - resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" - integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== +fraction.js@^5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" + integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ== frappe-ui@^0.1.272: version "0.1.272" @@ -2195,11 +2246,6 @@ lucide-static@^0.545.0: resolved "https://registry.yarnpkg.com/lucide-static/-/lucide-static-0.545.0.tgz#69dad91d1bbd53b70df6c21f0877c16a1e69cea0" integrity sha512-2i81WNw3y+sN17gG75DKZbd43pGJREyoAQRK5IG8djCWhISoK6Ri1ovbKwOjFR+St2LHTQhu+EJygmUIWMotew== -lucide-vue-next@^0.575.0: - version "0.575.0" - resolved "https://registry.yarnpkg.com/lucide-vue-next/-/lucide-vue-next-0.575.0.tgz#a782e0c00932dbe16ebf82e12030d9223895b62d" - integrity sha512-UHzA3cYMCgBLyGay5R9IQaidwV0NLocx7cIBnFt8vJ9Xhl6IM/oKD0fUhoCUuouFta15SX1rLXVoko9s3TzWMA== - magic-string@^0.30.17: version "0.30.19" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" @@ -2336,21 +2382,16 @@ nanoid@^5.0.7: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.5.tgz#f7597f9d9054eb4da9548cdd53ca70f1790e87de" integrity sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw== -node-releases@^2.0.19: - version "2.0.20" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.20.tgz#e26bb79dbdd1e64a146df389c699014c611cbc27" - integrity sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA== +node-releases@^2.0.36: + version "2.0.38" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.38.tgz#791569b9e4424a044e12c3abfad418ed83ce9947" + integrity sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw== normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== - object-assign@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -2398,6 +2439,31 @@ orderedmap@^2.0.0: resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== +oxlint@^1.64.0: + version "1.64.0" + resolved "https://registry.yarnpkg.com/oxlint/-/oxlint-1.64.0.tgz#f7ae18032e08d2bceaf94b4cb99e3657930b7d34" + integrity sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ== + optionalDependencies: + "@oxlint/binding-android-arm-eabi" "1.64.0" + "@oxlint/binding-android-arm64" "1.64.0" + "@oxlint/binding-darwin-arm64" "1.64.0" + "@oxlint/binding-darwin-x64" "1.64.0" + "@oxlint/binding-freebsd-x64" "1.64.0" + "@oxlint/binding-linux-arm-gnueabihf" "1.64.0" + "@oxlint/binding-linux-arm-musleabihf" "1.64.0" + "@oxlint/binding-linux-arm64-gnu" "1.64.0" + "@oxlint/binding-linux-arm64-musl" "1.64.0" + "@oxlint/binding-linux-ppc64-gnu" "1.64.0" + "@oxlint/binding-linux-riscv64-gnu" "1.64.0" + "@oxlint/binding-linux-riscv64-musl" "1.64.0" + "@oxlint/binding-linux-s390x-gnu" "1.64.0" + "@oxlint/binding-linux-x64-gnu" "1.64.0" + "@oxlint/binding-linux-x64-musl" "1.64.0" + "@oxlint/binding-openharmony-arm64" "1.64.0" + "@oxlint/binding-win32-arm64-msvc" "1.64.0" + "@oxlint/binding-win32-ia32-msvc" "1.64.0" + "@oxlint/binding-win32-x64-msvc" "1.64.0" + package-json-from-dist@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" @@ -2473,12 +2539,12 @@ pify@^2.3.0: resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== -pinia@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/pinia/-/pinia-3.0.3.tgz#f412019bdeb2f45e85927b432803190343e12d89" - integrity sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA== +pinia@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/pinia/-/pinia-3.0.4.tgz#75dde12784a61e34c1fa6abcd13c1a1061c360c0" + integrity sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw== dependencies: - "@vue/devtools-api" "^7.7.2" + "@vue/devtools-api" "^7.7.7" pirates@^4.0.1: version "4.0.7" @@ -2503,17 +2569,17 @@ pkg-types@^2.1.0, pkg-types@^2.3.0: exsolve "^1.0.7" pathe "^2.0.3" -playwright-core@1.59.1: - version "1.59.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2" - integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg== +playwright-core@1.60.0: + version "1.60.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.60.0.tgz#24e0d9cc4730713db5dffcace29b5e4696b1907a" + integrity sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA== -playwright@1.59.1: - version "1.59.1" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a" - integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw== +playwright@1.60.0: + version "1.60.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.60.0.tgz#89710863a51f21112633ef8b6b182594d3bfd7b5" + integrity sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA== dependencies: - playwright-core "1.59.1" + playwright-core "1.60.0" optionalDependencies: fsevents "2.3.2" @@ -2569,10 +2635,10 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.47, postcss@^8.5.10, postcss@^8.5.8: - version "8.5.10" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" - integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== +postcss@^8.4.47, postcss@^8.5.10, postcss@^8.5.14, postcss@^8.5.8: + version "8.5.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" + integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" @@ -3224,6 +3290,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +torph@^0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/torph/-/torph-0.0.9.tgz#00026fa3f14685b5a380e87202674bb2aa84d38e" + integrity sha512-WrFMtJwqXCfIXbLNuTOwHWff0XVm/Ewctb+71bFis3HykPvCXl1CHXapU0r67pQhAI323fsA1L2pGPeqxXeRRA== + tough-cookie@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76" @@ -3273,10 +3344,10 @@ ufo@^1.6.1: resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== -undici-types@~7.19.0: - version "7.19.2" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" - integrity sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg== +undici-types@~7.21.0: + version "7.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.21.0.tgz#433f7dd1b5daa9ab4dacb721a5e11a8de51eadda" + integrity sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ== undici@^7.24.5: version "7.24.7" @@ -3358,10 +3429,10 @@ unplugin@^2.2.2, unplugin@^2.3.4, unplugin@^2.3.5: picomatch "^4.0.3" webpack-virtual-modules "^0.6.2" -update-browserslist-db@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" - integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== +update-browserslist-db@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== dependencies: escalade "^3.2.0" picocolors "^1.1.1" @@ -3429,10 +3500,10 @@ vue-demi@>=0.13.0, vue-demi@>=0.14.8: resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== -vue-router@^4.5.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.5.1.tgz#47bffe2d3a5479d2886a9a244547a853aa0abf69" - integrity sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw== +vue-router@^4.6.4: + version "4.6.4" + resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.6.4.tgz#a0a9cb9ef811a106d249e4bb9313d286718020d8" + integrity sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg== dependencies: "@vue/devtools-api" "^6.6.4" @@ -3441,13 +3512,13 @@ vue-sonner@^2.0.9: resolved "https://registry.yarnpkg.com/vue-sonner/-/vue-sonner-2.0.9.tgz#32ec9a394eb8903bb40e48d974ce55ffa6be8dc4" integrity sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw== -vue-tsc@^3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-3.2.4.tgz#a8cebd4b44e6804a99f4d88a8161a4bfb293c3b4" - integrity sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g== +vue-tsc@^3.2.8: + version "3.2.8" + resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-3.2.8.tgz#92e6190f198b460c92b35f4f66eb791e374f0c01" + integrity sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog== dependencies: - "@volar/typescript" "2.4.27" - "@vue/language-core" "3.2.4" + "@volar/typescript" "2.4.28" + "@vue/language-core" "3.2.8" vue@^3.5.13, vue@^3.5.33: version "3.5.33"