Compare commits
36 Commits
6eab4d9744
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa49d78a84 | |||
| afbdea6b13 | |||
| 915f3b7f2f | |||
| f61432ad8a | |||
| 7b0cfe4448 | |||
| 7b99087cbb | |||
| ad00f5c195 | |||
| b8664b5c3e | |||
| 799b16578d | |||
| b5be5b4448 | |||
| ec62fcee51 | |||
| cad44db4c7 | |||
| 9e8cf28d77 | |||
| f3eb9782c6 | |||
| 8378c44ad7 | |||
| 8cf5aacf21 | |||
| 59d04a4d7e | |||
| b5bf2eb37e | |||
| ea1aec67dc | |||
| 8af82f5900 | |||
| 4d66718b05 | |||
| e90d412956 | |||
| ac7e1959cf | |||
| 51fbbd7101 | |||
| 2b780a12c2 | |||
| 96b96bcaaa | |||
| 9ae91418e0 | |||
| 005ba663d6 | |||
| 130a907351 | |||
| 33fe404ca9 | |||
| 361e969eda | |||
| a45563685f | |||
| 71683482e1 | |||
| 87fbc1dda8 | |||
| 23218703f9 | |||
| b37f4363eb |
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: playwright-generate-test
|
||||
description: 'Generate a Playwright test based on a scenario using Playwright MCP'
|
||||
---
|
||||
|
||||
# Test Generation with Playwright MCP
|
||||
|
||||
Your goal is to generate a Playwright test based on the provided scenario after completing all prescribed steps.
|
||||
|
||||
## Specific Instructions
|
||||
|
||||
- You are given a scenario, and you need to generate a playwright test for it. If the user does not provide a scenario, you will ask them to provide one.
|
||||
- DO NOT generate test code prematurely or based solely on the scenario without completing all prescribed steps.
|
||||
- DO run steps one by one using the tools provided by the Playwright MCP.
|
||||
- Only after all steps are completed, emit a Playwright TypeScript test that uses `@playwright/test` based on message history
|
||||
- Save generated test file in the tests directory
|
||||
- Execute the test file and iterate until the test passes
|
||||
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,14 +0,0 @@
|
||||
This skill includes material derived from the Microsoft playwright-cli repository.
|
||||
|
||||
Source:
|
||||
- Repository: microsoft/playwright-cli
|
||||
- Path: skills/playwright-cli/SKILL.md
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
See LICENSE.txt in this directory.
|
||||
|
||||
Modifications:
|
||||
- Adapted for the Codex skill collection.
|
||||
- Added a wrapper script and local reference guides.
|
||||
@@ -1,147 +0,0 @@
|
||||
---
|
||||
name: "playwright"
|
||||
description: "Use when the task requires automating a real browser from the terminal (navigation, form filling, snapshots, screenshots, data extraction, UI-flow debugging) via `playwright-cli` or the bundled wrapper script."
|
||||
---
|
||||
|
||||
|
||||
# Playwright CLI Skill
|
||||
|
||||
Drive a real browser from the terminal using `playwright-cli`. Prefer the bundled wrapper script so the CLI works even when it is not globally installed.
|
||||
Treat this skill as CLI-first automation. Do not pivot to `@playwright/test` unless the user explicitly asks for test files.
|
||||
|
||||
## Prerequisite check (required)
|
||||
|
||||
Before proposing commands, check whether `npx` is available (the wrapper depends on it):
|
||||
|
||||
```bash
|
||||
command -v npx >/dev/null 2>&1
|
||||
```
|
||||
|
||||
If it is not available, pause and ask the user to install Node.js/npm (which provides `npx`). Provide these steps verbatim:
|
||||
|
||||
```bash
|
||||
# Verify Node/npm are installed
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
# If missing, install Node.js/npm, then:
|
||||
npm install -g @playwright/cli@latest
|
||||
playwright-cli --help
|
||||
```
|
||||
|
||||
Once `npx` is present, proceed with the wrapper script. A global install of `playwright-cli` is optional.
|
||||
|
||||
## Skill path (set once)
|
||||
|
||||
```bash
|
||||
export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
|
||||
export PWCLI="$CODEX_HOME/skills/playwright/scripts/playwright_cli.sh"
|
||||
```
|
||||
|
||||
User-scoped skills install under `$CODEX_HOME/skills` (default: `~/.codex/skills`).
|
||||
|
||||
## Quick start
|
||||
|
||||
Use the wrapper script:
|
||||
|
||||
```bash
|
||||
"$PWCLI" open https://playwright.dev --headed
|
||||
"$PWCLI" snapshot
|
||||
"$PWCLI" click e15
|
||||
"$PWCLI" type "Playwright"
|
||||
"$PWCLI" press Enter
|
||||
"$PWCLI" screenshot
|
||||
```
|
||||
|
||||
If the user prefers a global install, this is also valid:
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
playwright-cli --help
|
||||
```
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. Open the page.
|
||||
2. Snapshot to get stable element refs.
|
||||
3. Interact using refs from the latest snapshot.
|
||||
4. Re-snapshot after navigation or significant DOM changes.
|
||||
5. Capture artifacts (screenshot, pdf, traces) when useful.
|
||||
|
||||
Minimal loop:
|
||||
|
||||
```bash
|
||||
"$PWCLI" open https://example.com
|
||||
"$PWCLI" snapshot
|
||||
"$PWCLI" click e3
|
||||
"$PWCLI" snapshot
|
||||
```
|
||||
|
||||
## When to snapshot again
|
||||
|
||||
Snapshot again after:
|
||||
|
||||
- navigation
|
||||
- clicking elements that change the UI substantially
|
||||
- opening/closing modals or menus
|
||||
- tab switches
|
||||
|
||||
Refs can go stale. When a command fails due to a missing ref, snapshot again.
|
||||
|
||||
## Recommended patterns
|
||||
|
||||
### Form fill and submit
|
||||
|
||||
```bash
|
||||
"$PWCLI" open https://example.com/form
|
||||
"$PWCLI" snapshot
|
||||
"$PWCLI" fill e1 "user@example.com"
|
||||
"$PWCLI" fill e2 "password123"
|
||||
"$PWCLI" click e3
|
||||
"$PWCLI" snapshot
|
||||
```
|
||||
|
||||
### Debug a UI flow with traces
|
||||
|
||||
```bash
|
||||
"$PWCLI" open https://example.com --headed
|
||||
"$PWCLI" tracing-start
|
||||
# ...interactions...
|
||||
"$PWCLI" tracing-stop
|
||||
```
|
||||
|
||||
### Multi-tab work
|
||||
|
||||
```bash
|
||||
"$PWCLI" tab-new https://example.com
|
||||
"$PWCLI" tab-list
|
||||
"$PWCLI" tab-select 0
|
||||
"$PWCLI" snapshot
|
||||
```
|
||||
|
||||
## Wrapper script
|
||||
|
||||
The wrapper script uses `npx --package @playwright/cli playwright-cli` so the CLI can run without a global install:
|
||||
|
||||
```bash
|
||||
"$PWCLI" --help
|
||||
```
|
||||
|
||||
Prefer the wrapper unless the repository already standardizes on a global install.
|
||||
|
||||
## References
|
||||
|
||||
Open only what you need:
|
||||
|
||||
- CLI command reference: `references/cli.md`
|
||||
- Practical workflows and troubleshooting: `references/workflows.md`
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Always snapshot before referencing element ids like `e12`.
|
||||
- Re-snapshot when refs seem stale.
|
||||
- Prefer explicit commands over `eval` and `run-code` unless needed.
|
||||
- When you do not have a fresh snapshot, use placeholder refs like `eX` and say why; do not bypass refs with `run-code`.
|
||||
- Use `--headed` when a visual check will help.
|
||||
- When capturing artifacts in this repo, use `output/playwright/` and avoid introducing new top-level artifact folders.
|
||||
- Default to CLI commands and workflows, not Playwright test specs.
|
||||
@@ -1,6 +0,0 @@
|
||||
interface:
|
||||
display_name: "Playwright CLI Skill"
|
||||
short_description: "Automate real browsers from the terminal"
|
||||
icon_small: "./assets/playwright-small.svg"
|
||||
icon_large: "./assets/playwright.png"
|
||||
default_prompt: "Automate this browser workflow with Playwright and produce a reliable script with run steps."
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="m8.55 7.568.124.028 5.16 1.548.137.054c.606.294.645 1.165.068 1.512l-.133.066-2.236.894-.894 2.236c-.285.713-1.263.714-1.578.065l-.054-.138-1.548-5.16a.866.866 0 0 1 .954-1.105ZM10 12.983l.715-1.787.037-.08a.865.865 0 0 1 .445-.402L12.983 10 8.721 8.72l1.278 4.262ZM4.723 10.38a.532.532 0 0 1 .752.752l-1.414 1.414a.532.532 0 1 1-.752-.752l1.414-1.414ZM2.27 5.86l1.932.517.1.039a.533.533 0 0 1-.269 1.007l-.106-.018-1.932-.517-.101-.039a.532.532 0 0 1 .27-1.006l.106.017Zm9.608-2.62a.532.532 0 0 1 .668.82l-1.414 1.414a.532.532 0 1 1-.752-.752l1.414-1.414.084-.068ZM6.237 1.618a.532.532 0 0 1 .652.377l.518 1.932.017.106a.533.533 0 0 1-1.007.27l-.039-.101-.517-1.932-.017-.106a.532.532 0 0 1 .393-.546Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 828 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,116 +0,0 @@
|
||||
# Playwright CLI Reference
|
||||
|
||||
Use the wrapper script unless the CLI is already installed globally:
|
||||
|
||||
```bash
|
||||
export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
|
||||
export PWCLI="$CODEX_HOME/skills/playwright/scripts/playwright_cli.sh"
|
||||
"$PWCLI" --help
|
||||
```
|
||||
|
||||
User-scoped skills install under `$CODEX_HOME/skills` (default: `~/.codex/skills`).
|
||||
|
||||
Optional convenience alias:
|
||||
|
||||
```bash
|
||||
alias pwcli="$PWCLI"
|
||||
```
|
||||
|
||||
## Core
|
||||
|
||||
```bash
|
||||
pwcli open https://example.com
|
||||
pwcli close
|
||||
pwcli snapshot
|
||||
pwcli click e3
|
||||
pwcli dblclick e7
|
||||
pwcli type "search terms"
|
||||
pwcli press Enter
|
||||
pwcli fill e5 "user@example.com"
|
||||
pwcli drag e2 e8
|
||||
pwcli hover e4
|
||||
pwcli select e9 "option-value"
|
||||
pwcli upload ./document.pdf
|
||||
pwcli check e12
|
||||
pwcli uncheck e12
|
||||
pwcli eval "document.title"
|
||||
pwcli eval "el => el.textContent" e5
|
||||
pwcli dialog-accept
|
||||
pwcli dialog-accept "confirmation text"
|
||||
pwcli dialog-dismiss
|
||||
pwcli resize 1920 1080
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
```bash
|
||||
pwcli go-back
|
||||
pwcli go-forward
|
||||
pwcli reload
|
||||
```
|
||||
|
||||
## Keyboard
|
||||
|
||||
```bash
|
||||
pwcli press Enter
|
||||
pwcli press ArrowDown
|
||||
pwcli keydown Shift
|
||||
pwcli keyup Shift
|
||||
```
|
||||
|
||||
## Mouse
|
||||
|
||||
```bash
|
||||
pwcli mousemove 150 300
|
||||
pwcli mousedown
|
||||
pwcli mousedown right
|
||||
pwcli mouseup
|
||||
pwcli mouseup right
|
||||
pwcli mousewheel 0 100
|
||||
```
|
||||
|
||||
## Save as
|
||||
|
||||
```bash
|
||||
pwcli screenshot
|
||||
pwcli screenshot e5
|
||||
pwcli pdf
|
||||
```
|
||||
|
||||
## Tabs
|
||||
|
||||
```bash
|
||||
pwcli tab-list
|
||||
pwcli tab-new
|
||||
pwcli tab-new https://example.com/page
|
||||
pwcli tab-close
|
||||
pwcli tab-close 2
|
||||
pwcli tab-select 0
|
||||
```
|
||||
|
||||
## DevTools
|
||||
|
||||
```bash
|
||||
pwcli console
|
||||
pwcli console warning
|
||||
pwcli network
|
||||
pwcli run-code "await page.waitForTimeout(1000)"
|
||||
pwcli tracing-start
|
||||
pwcli tracing-stop
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
Use a named session to isolate work:
|
||||
|
||||
```bash
|
||||
pwcli --session todo open https://demo.playwright.dev/todomvc
|
||||
pwcli --session todo snapshot
|
||||
```
|
||||
|
||||
Or set an environment variable once:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION=todo
|
||||
pwcli open https://demo.playwright.dev/todomvc
|
||||
```
|
||||
@@ -1,95 +0,0 @@
|
||||
# Playwright CLI Workflows
|
||||
|
||||
Use the wrapper script and snapshot often.
|
||||
Assume `PWCLI` is set and `pwcli` is an alias for `"$PWCLI"`.
|
||||
In this repo, run commands from `output/playwright/<label>/` to keep artifacts contained.
|
||||
|
||||
## Standard interaction loop
|
||||
|
||||
```bash
|
||||
pwcli open https://example.com
|
||||
pwcli snapshot
|
||||
pwcli click e3
|
||||
pwcli snapshot
|
||||
```
|
||||
|
||||
## Form submission
|
||||
|
||||
```bash
|
||||
pwcli open https://example.com/form --headed
|
||||
pwcli snapshot
|
||||
pwcli fill e1 "user@example.com"
|
||||
pwcli fill e2 "password123"
|
||||
pwcli click e3
|
||||
pwcli snapshot
|
||||
pwcli screenshot
|
||||
```
|
||||
|
||||
## Data extraction
|
||||
|
||||
```bash
|
||||
pwcli open https://example.com
|
||||
pwcli snapshot
|
||||
pwcli eval "document.title"
|
||||
pwcli eval "el => el.textContent" e12
|
||||
```
|
||||
|
||||
## Debugging and inspection
|
||||
|
||||
Capture console messages and network activity after reproducing an issue:
|
||||
|
||||
```bash
|
||||
pwcli console warning
|
||||
pwcli network
|
||||
```
|
||||
|
||||
Record a trace around a suspicious flow:
|
||||
|
||||
```bash
|
||||
pwcli tracing-start
|
||||
# reproduce the issue
|
||||
pwcli tracing-stop
|
||||
pwcli screenshot
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
Use sessions to isolate work across projects:
|
||||
|
||||
```bash
|
||||
pwcli --session marketing open https://example.com
|
||||
pwcli --session marketing snapshot
|
||||
pwcli --session checkout open https://example.com/checkout
|
||||
```
|
||||
|
||||
Or set the session once:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION=checkout
|
||||
pwcli open https://example.com/checkout
|
||||
```
|
||||
|
||||
## Configuration file
|
||||
|
||||
By default, the CLI reads `playwright-cli.json` from the current directory. Use `--config` to point at a specific file.
|
||||
|
||||
Minimal example:
|
||||
|
||||
```json
|
||||
{
|
||||
"browser": {
|
||||
"launchOptions": {
|
||||
"headless": false
|
||||
},
|
||||
"contextOptions": {
|
||||
"viewport": { "width": 1280, "height": 720 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If an element ref fails, run `pwcli snapshot` again and retry.
|
||||
- If the page looks wrong, re-open with `--headed` and resize the window.
|
||||
- If a flow depends on prior state, use a named `--session`.
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v npx >/dev/null 2>&1; then
|
||||
echo "Error: npx is required but not found on PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
has_session_flag="false"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--session|--session=*)
|
||||
has_session_flag="true"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cmd=(npx --yes --package @playwright/cli playwright-cli)
|
||||
if [[ "${has_session_flag}" != "true" && -n "${PLAYWRIGHT_CLI_SESSION:-}" ]]; then
|
||||
cmd+=(--session "${PLAYWRIGHT_CLI_SESSION}")
|
||||
fi
|
||||
cmd+=("$@")
|
||||
|
||||
exec "${cmd[@]}"
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 hyf0, SerKo <https://github.com/serkodev>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,154 +0,0 @@
|
||||
---
|
||||
name: vue-best-practices
|
||||
description: MUST be used for Vue.js tasks. Strongly recommends Composition API with `<script setup>` and TypeScript as the standard approach. Covers Vue 3, SSR, Volar, vue-tsc. Load for any Vue, .vue files, Vue Router, Pinia, or Vite with Vue work. ALWAYS use Composition API unless the project explicitly requires Options API.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: github.com/vuejs-ai
|
||||
version: "18.0.0"
|
||||
---
|
||||
|
||||
# Vue Best Practices Workflow
|
||||
|
||||
Use this skill as an instruction set. Follow the workflow in order unless the user explicitly asks for a different order.
|
||||
|
||||
## Core Principles
|
||||
- **Keep state predictable:** one source of truth, derive everything else.
|
||||
- **Make data flow explicit:** Props down, Events up for most cases.
|
||||
- **Favor small, focused components:** easier to test, reuse, and maintain.
|
||||
- **Avoid unnecessary re-renders:** use computed properties and watchers wisely.
|
||||
- **Readability counts:** write clear, self-documenting code.
|
||||
|
||||
## 1) Confirm architecture before coding (required)
|
||||
|
||||
- Default stack: Vue 3 + Composition API + `<script setup lang="ts">`.
|
||||
- If the project explicitly uses Options API, load `vue-options-api-best-practices` skill if available.
|
||||
- If the project explicitly uses JSX, load `vue-jsx-best-practices` skill if available.
|
||||
|
||||
### 1.1 Must-read core references (required)
|
||||
|
||||
- Before implementing any Vue task, make sure to read and apply these core references:
|
||||
- `references/reactivity.md`
|
||||
- `references/sfc.md`
|
||||
- `references/component-data-flow.md`
|
||||
- `references/composables.md`
|
||||
- Keep these references in active working context for the entire task, not only when a specific issue appears.
|
||||
|
||||
### 1.2 Plan component boundaries before coding (required)
|
||||
|
||||
Create a brief component map before implementation for any non-trivial feature.
|
||||
|
||||
- Define each component's single responsibility in one sentence.
|
||||
- Keep entry/root and route-level view components as composition surfaces by default.
|
||||
- Move feature UI and feature logic out of entry/root/view components unless the task is intentionally a tiny single-file demo.
|
||||
- Define props/emits contracts for each child component in the map.
|
||||
- Prefer a feature folder layout (`components/<feature>/...`, `composables/use<Feature>.ts`) when adding more than one component.
|
||||
|
||||
## 2) Apply essential Vue foundations (required)
|
||||
|
||||
These are essential, must-know foundations. Apply all of them in every Vue task using the core references already loaded in section `1.1`.
|
||||
|
||||
### Reactivity
|
||||
|
||||
- Must-read reference from `1.1`: [reactivity](references/reactivity.md)
|
||||
- Keep source state minimal (`ref`/`reactive`), derive everything possible with `computed`.
|
||||
- Use watchers for side effects if needed.
|
||||
- Avoid recomputing expensive logic in templates.
|
||||
|
||||
### SFC structure and template safety
|
||||
|
||||
- Must-read reference from `1.1`: [sfc](references/sfc.md)
|
||||
- Keep SFC sections in this order: `<script>` → `<template>` → `<style>`.
|
||||
- Keep SFC responsibilities focused; split large components.
|
||||
- Keep templates declarative; move branching/derivation to script.
|
||||
- Apply Vue template safety rules (`v-html`, list rendering, conditional rendering choices).
|
||||
|
||||
### Keep components focused
|
||||
|
||||
Split a component when it has **more than one clear responsibility** (e.g. data orchestration + UI, or multiple independent UI sections).
|
||||
|
||||
- Prefer **smaller components + composables** over one “mega component”
|
||||
- Move **UI sections** into child components (props in, events out).
|
||||
- Move **state/side effects** into composables (`useXxx()`).
|
||||
|
||||
Apply objective split triggers. Split the component if **any** condition is true:
|
||||
|
||||
- It owns both orchestration/state and substantial presentational markup for multiple sections.
|
||||
- It has 3+ distinct UI sections (for example: form, filters, list, footer/status).
|
||||
- A template block is repeated or could become reusable (item rows, cards, list entries).
|
||||
|
||||
Entry/root and route view rule:
|
||||
|
||||
- Keep entry/root and route view components thin: app shell/layout, provider wiring, and feature composition.
|
||||
- Do not place full feature implementations in entry/root/view components when those features contain independent parts.
|
||||
- For CRUD/list features (todo, table, catalog, inbox), split at least into:
|
||||
- feature container component
|
||||
- input/form component
|
||||
- list (and/or item) component
|
||||
- footer/actions or filter/status component
|
||||
- Allow a single-file implementation only for very small throwaway demos; if chosen, explicitly justify why splitting is unnecessary.
|
||||
|
||||
### Component data flow
|
||||
|
||||
- Must-read reference from `1.1`: [component-data-flow](references/component-data-flow.md)
|
||||
- Use props down, events up as the primary model.
|
||||
- Use `v-model` only for true two-way component contracts.
|
||||
- Use provide/inject only for deep-tree dependencies or shared context.
|
||||
- Keep contracts explicit and typed with `defineProps`, `defineEmits`, and `InjectionKey` as needed.
|
||||
|
||||
### Composables
|
||||
|
||||
- Must-read reference from `1.1`: [composables](references/composables.md)
|
||||
- Extract logic into composables when it is reused, stateful, or side-effect heavy.
|
||||
- Keep composable APIs small, typed, and predictable.
|
||||
- Separate feature logic from presentational components.
|
||||
|
||||
## 3) Consider optional features only when requirements call for them
|
||||
|
||||
### 3.1 Standard optional features
|
||||
|
||||
Do not add these by default. Load the matching reference only when the requirement exists.
|
||||
|
||||
- Slots: parent needs to control child content/layout -> [component-slots](references/component-slots.md)
|
||||
- Fallthrough attributes: wrapper/base components must forward attrs/events safely -> [component-fallthrough-attrs](references/component-fallthrough-attrs.md)
|
||||
- Built-in component `<KeepAlive>` for stateful view caching -> [component-keep-alive](references/component-keep-alive.md)
|
||||
- Built-in component `<Teleport>` for overlays/portals -> [component-teleport](references/component-teleport.md)
|
||||
- Built-in component `<Suspense>` for async subtree fallback boundaries -> [component-suspense](references/component-suspense.md)
|
||||
- Animation-related features: pick the simplest approach that matches the required motion behavior.
|
||||
- Built-in component `<Transition>` for enter/leave effects -> [transition](references/component-transition.md)
|
||||
- Built-in component `<TransitionGroup>` for animated list mutations -> [transition-group](references/component-transition-group.md)
|
||||
- Class-based animation for non-enter/leave effects -> [animation-class-based-technique](references/animation-class-based-technique.md)
|
||||
- State-driven animation for user-input-driven animation -> [animation-state-driven-technique](references/animation-state-driven-technique.md)
|
||||
|
||||
### 3.2 Less-common optional features
|
||||
|
||||
Use these only when there is explicit product or technical need.
|
||||
|
||||
- Directives: behavior is DOM-specific and not a good composable/component fit -> [directives](references/directives.md)
|
||||
- Async components: heavy/rarely-used UI should be lazy loaded -> [component-async](references/component-async.md)
|
||||
- Render functions only when templates cannot express the requirement -> [render-functions](references/render-functions.md)
|
||||
- Plugins when behavior must be installed app-wide -> [plugins](references/plugins.md)
|
||||
- State management patterns: app-wide shared state crosses feature boundaries -> [state-management](references/state-management.md)
|
||||
|
||||
## 4) Run performance optimization after behavior is correct
|
||||
|
||||
Performance work is a post-functionality pass. Do not optimize before core behavior is implemented and verified.
|
||||
|
||||
- Large list rendering bottlenecks -> [perf-virtualize-large-lists](references/perf-virtualize-large-lists.md)
|
||||
- Static subtrees re-rendering unnecessarily -> [perf-v-once-v-memo-directives](references/perf-v-once-v-memo-directives.md)
|
||||
- Over-abstraction in hot list paths -> [perf-avoid-component-abstraction-in-lists](references/perf-avoid-component-abstraction-in-lists.md)
|
||||
- Expensive updates triggered too often -> [updated-hook-performance](references/updated-hook-performance.md)
|
||||
|
||||
## 5) Final self-check before finishing
|
||||
|
||||
- Core behavior works and matches requirements.
|
||||
- All must-read references were read and applied.
|
||||
- Reactivity model is minimal and predictable.
|
||||
- SFC structure and template rules are followed.
|
||||
- Components are focused and well-factored, splitting when needed.
|
||||
- Entry/root and route view components remain composition surfaces unless there is an explicit small-demo exception.
|
||||
- Component split decisions are explicit and defensible (responsibility boundaries are clear).
|
||||
- Data flow contracts are explicit and typed.
|
||||
- Composables are used where reuse/complexity justifies them.
|
||||
- Moved state/side effects into composables if applicable
|
||||
- Optional features are used only when requirements demand them.
|
||||
- Performance changes were applied only after functionality was complete.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Sync Info
|
||||
|
||||
- **Source:** `vendor/vuejs-ai/skills/vue-best-practices`
|
||||
- **Git SHA:** `f3dd1bf4d3ac78331bdc903e4519d561c538ca6a`
|
||||
- **Synced:** 2026-03-16
|
||||
@@ -1,254 +0,0 @@
|
||||
---
|
||||
title: Use Class-based Animations for Non-Enter/Leave Effects
|
||||
impact: LOW
|
||||
impactDescription: Class-based animations are simpler and more performant for elements that remain in the DOM
|
||||
type: best-practice
|
||||
tags: [vue3, animation, css, class-binding, state]
|
||||
---
|
||||
|
||||
# Use Class-based Animations for Non-Enter/Leave Effects
|
||||
|
||||
**Impact: LOW** - For animations on elements that are not entering or leaving the DOM, use CSS class-based animations triggered by Vue's reactive state. This is simpler than `<Transition>` and more appropriate for feedback animations like shake, pulse, or highlight effects.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use class-based animations for elements staying in the DOM
|
||||
- Use `<Transition>` only for enter/leave animations
|
||||
- Combine CSS animations with Vue's class bindings (`:class`)
|
||||
- Consider using `setTimeout` to auto-remove animation classes
|
||||
|
||||
**When to Use Class-based Animations:**
|
||||
- User feedback (shake on error, pulse on success)
|
||||
- Attention-grabbing effects (highlight changes)
|
||||
- Hover/focus states that need more than CSS transitions
|
||||
- Any animation where the element stays mounted
|
||||
|
||||
**When to Use Transition Component:**
|
||||
- Elements entering/leaving the DOM (v-if/v-show)
|
||||
- Route transitions
|
||||
- List item additions/removals
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div :class="{ shake: showError }">
|
||||
<button @click="submitForm">Submit</button>
|
||||
<span v-if="showError">This feature is disabled!</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showError = ref(false)
|
||||
|
||||
function submitForm() {
|
||||
if (!isValid()) {
|
||||
// Trigger shake animation
|
||||
showError.value = true
|
||||
|
||||
// Auto-remove class after animation completes
|
||||
setTimeout(() => {
|
||||
showError.value = false
|
||||
}, 820) // Match animation duration
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.shake {
|
||||
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
transform: translate3d(0, 0, 0); /* Enable GPU acceleration */
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
||||
20%, 80% { transform: translate3d(2px, 0, 0); }
|
||||
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
||||
40%, 60% { transform: translate3d(4px, 0, 0); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Common Animation Patterns
|
||||
|
||||
### Pulse on Success
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<button
|
||||
@click="save"
|
||||
:class="{ pulse: saved }"
|
||||
>
|
||||
{{ saved ? 'Saved!' : 'Save' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const saved = ref(false)
|
||||
|
||||
async function save() {
|
||||
await saveData()
|
||||
saved.value = true
|
||||
setTimeout(() => saved.value = false, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.pulse {
|
||||
animation: pulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Highlight on Change
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ highlight: justUpdated }"
|
||||
>
|
||||
Value: {{ value }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const value = ref(0)
|
||||
const justUpdated = ref(false)
|
||||
|
||||
watch(value, () => {
|
||||
justUpdated.value = true
|
||||
setTimeout(() => justUpdated.value = false, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.highlight {
|
||||
animation: highlight 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
0% { background-color: yellow; }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Bounce Attention
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ bounce: needsAttention }"
|
||||
@animationend="needsAttention = false"
|
||||
>
|
||||
<BellIcon />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const needsAttention = ref(false)
|
||||
|
||||
function notifyUser() {
|
||||
needsAttention.value = true
|
||||
// No setTimeout needed - using animationend event
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bounce {
|
||||
animation: bounce 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Using animationend Event
|
||||
|
||||
Instead of `setTimeout`, use the `animationend` event for cleaner code:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ animate: isAnimating }"
|
||||
@animationend="isAnimating = false"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isAnimating = ref(false)
|
||||
|
||||
function triggerAnimation() {
|
||||
isAnimating.value = true
|
||||
// Class is automatically removed when animation ends
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Composable for Reusable Animations
|
||||
|
||||
```javascript
|
||||
// composables/useAnimation.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useAnimation(duration = 500) {
|
||||
const isAnimating = ref(false)
|
||||
|
||||
function trigger() {
|
||||
isAnimating.value = true
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return {
|
||||
isAnimating,
|
||||
trigger
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAnimation } from '@/composables/useAnimation'
|
||||
|
||||
const shake = useAnimation(820)
|
||||
const pulse = useAnimation(500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="{ shake: shake.isAnimating.value }"
|
||||
@click="shake.trigger()"
|
||||
>
|
||||
Shake me
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="{ pulse: pulse.isAnimating.value }"
|
||||
@click="pulse.trigger()"
|
||||
>
|
||||
Pulse me
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
@@ -1,291 +0,0 @@
|
||||
---
|
||||
title: State-driven Animations with CSS Transitions and Style Bindings
|
||||
impact: LOW
|
||||
impactDescription: Combining Vue's reactive style bindings with CSS transitions creates smooth, interactive animations
|
||||
type: best-practice
|
||||
tags: [vue3, animation, css, transition, style-binding, state, interactive]
|
||||
---
|
||||
|
||||
# State-driven Animations with CSS Transitions and Style Bindings
|
||||
|
||||
**Impact: LOW** - For responsive, interactive animations that react to user input or state changes, combine Vue's dynamic style bindings with CSS transitions. This creates smooth animations that interpolate values in real-time based on state.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use `:style` binding for dynamic properties that change frequently
|
||||
- Add CSS `transition` property to smoothly animate between values
|
||||
- Consider using `transform` and `opacity` for GPU-accelerated animations
|
||||
- For complex value interpolation, use watchers with animation libraries
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
@mousemove="onMousemove"
|
||||
:style="{ backgroundColor: `hsl(${hue}, 80%, 50%)` }"
|
||||
class="interactive-area"
|
||||
>
|
||||
<p>Move your mouse across this div...</p>
|
||||
<p>Hue: {{ hue }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const hue = ref(0)
|
||||
|
||||
function onMousemove(e) {
|
||||
// Map mouse X position to hue (0-360)
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
hue.value = Math.round((e.clientX - rect.left) / rect.width * 360)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.interactive-area {
|
||||
transition: background-color 0.3s ease;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Following Mouse Position
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="container"
|
||||
@mousemove="onMousemove"
|
||||
>
|
||||
<div
|
||||
class="follower"
|
||||
:style="{
|
||||
transform: `translate(${x}px, ${y}px)`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
function onMousemove(e) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
x.value = e.clientX - rect.left
|
||||
y.value = e.clientY - rect.top
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.follower {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: blue;
|
||||
border-radius: 50%;
|
||||
/* Smooth following with transition */
|
||||
transition: transform 0.1s ease-out;
|
||||
/* Prevent the follower from triggering mousemove */
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Progress Animation
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="progress-container">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="progress"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const progress = ref(0)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.progress-container {
|
||||
height: 20px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4CAF50, #8BC34A);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Scroll-based Animation
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="hero"
|
||||
:style="{
|
||||
opacity: heroOpacity,
|
||||
transform: `translateY(${scrollOffset}px)`
|
||||
}"
|
||||
>
|
||||
<h1>Scroll Down</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const scrollY = ref(0)
|
||||
|
||||
const heroOpacity = computed(() => {
|
||||
return Math.max(0, 1 - scrollY.value / 300)
|
||||
})
|
||||
|
||||
const scrollOffset = computed(() => {
|
||||
return scrollY.value * 0.5 // Parallax effect
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
scrollY.value = window.scrollY
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Note: No transition for scroll-based animations - they should be instant */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Color Theme Transition
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="app"
|
||||
:style="themeStyles"
|
||||
>
|
||||
<button @click="toggleTheme">Toggle Theme</button>
|
||||
<p>Current theme: {{ isDark ? 'Dark' : 'Light' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
const themeStyles = computed(() => ({
|
||||
'--bg-color': isDark.value ? '#1a1a1a' : '#ffffff',
|
||||
'--text-color': isDark.value ? '#ffffff' : '#1a1a1a',
|
||||
backgroundColor: 'var(--bg-color)',
|
||||
color: 'var(--text-color)'
|
||||
}))
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.5s ease, color 0.5s ease;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Advanced: Numerical Tweening with Watchers
|
||||
|
||||
For smooth number animations (counters, stats), use watchers with animation libraries:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<input v-model.number="targetNumber" type="number" />
|
||||
<p class="counter">{{ displayNumber.toFixed(0) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive, watch } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const targetNumber = ref(0)
|
||||
const tweened = reactive({ value: 0 })
|
||||
|
||||
// Computed for display
|
||||
const displayNumber = computed(() => tweened.value)
|
||||
|
||||
watch(targetNumber, (newValue) => {
|
||||
gsap.to(tweened, {
|
||||
duration: 0.5,
|
||||
value: Number(newValue) || 0,
|
||||
ease: 'power2.out'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
```vue
|
||||
<style>
|
||||
/* GOOD: GPU-accelerated properties */
|
||||
.element {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* AVOID: Properties that trigger layout recalculation */
|
||||
.element {
|
||||
transition: width 0.3s ease, height 0.3s ease, margin 0.3s ease;
|
||||
}
|
||||
|
||||
/* For high-frequency updates, consider will-change */
|
||||
.frequently-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
title: Async Component Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Poor async component strategy can delay interactivity in SSR apps and create loading UI flicker
|
||||
type: best-practice
|
||||
tags: [vue3, async-components, ssr, hydration, performance, ux]
|
||||
---
|
||||
|
||||
# Async Component Best Practices
|
||||
|
||||
**Impact: MEDIUM** - Async components should reduce JavaScript cost without degrading perceived performance. Focus on hydration timing in SSR and stable loading UX.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use lazy hydration strategies for non-critical SSR component trees
|
||||
- Import only the hydration helpers you actually use
|
||||
- Keep `loadingComponent` delay near the default `200ms` unless real UX data suggests otherwise
|
||||
- Configure `delay` and `timeout` together for predictable loading behavior
|
||||
|
||||
## Use Lazy Hydration Strategies in SSR
|
||||
|
||||
In Vue 3.5+, async components can delay hydration until idle time, visibility, media query match, or user interaction.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const AsyncComments = defineAsyncComponent({
|
||||
loader: () => import('./Comments.vue')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defineAsyncComponent,
|
||||
hydrateOnVisible,
|
||||
hydrateOnIdle
|
||||
} from 'vue'
|
||||
|
||||
const AsyncComments = defineAsyncComponent({
|
||||
loader: () => import('./Comments.vue'),
|
||||
hydrate: hydrateOnVisible({ rootMargin: '100px' })
|
||||
})
|
||||
|
||||
const AsyncFooter = defineAsyncComponent({
|
||||
loader: () => import('./Footer.vue'),
|
||||
hydrate: hydrateOnIdle(5000)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Prevent Loading Spinner Flicker
|
||||
|
||||
Avoid showing loading UI immediately for components that usually resolve quickly.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import LoadingSpinner from './LoadingSpinner.vue'
|
||||
|
||||
const AsyncDashboard = defineAsyncComponent({
|
||||
loader: () => import('./Dashboard.vue'),
|
||||
loadingComponent: LoadingSpinner,
|
||||
delay: 0
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import LoadingSpinner from './LoadingSpinner.vue'
|
||||
import ErrorDisplay from './ErrorDisplay.vue'
|
||||
|
||||
const AsyncDashboard = defineAsyncComponent({
|
||||
loader: () => import('./Dashboard.vue'),
|
||||
loadingComponent: LoadingSpinner,
|
||||
errorComponent: ErrorDisplay,
|
||||
delay: 200,
|
||||
timeout: 30000
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Delay Guidelines
|
||||
|
||||
| Scenario | Recommended Delay |
|
||||
|----------|-------------------|
|
||||
| Small component, fast network | `200ms` |
|
||||
| Known heavy component | `100ms` |
|
||||
| Background or non-critical UI | `300-500ms` |
|
||||
@@ -1,307 +0,0 @@
|
||||
---
|
||||
title: Component Data Flow Best Practices
|
||||
impact: HIGH
|
||||
impactDescription: Clear data flow between components prevents state bugs, stale UI, and brittle coupling
|
||||
type: best-practice
|
||||
tags: [vue3, props, emits, v-model, provide-inject, data-flow, typescript]
|
||||
---
|
||||
|
||||
# Component Data Flow Best Practices
|
||||
|
||||
**Impact: HIGH** - Vue components stay reliable when data flow is explicit: props go down, events go up, `v-model` handles two-way bindings, and provide/inject supports cross-tree dependencies. Blurring these boundaries leads to stale state, hidden coupling, and hard-to-debug UI.
|
||||
|
||||
The main principle of data flow in Vue.js is **Props Down / Events Up**. This is the most maintainable default, and one-way flow scales well.
|
||||
|
||||
## Task List
|
||||
|
||||
- Treat props as read-only inputs
|
||||
- Use props/emit for component communication; reserve refs for imperative actions
|
||||
- When refs are required for imperative APIs, type them with template refs
|
||||
- Emit events instead of mutating parent state directly
|
||||
- Use `defineModel` for v-model in modern Vue (3.4+)
|
||||
- Handle v-model modifiers deliberately in child components
|
||||
- Use symbols for provide/inject keys to avoid props drilling (over ~3 layers)
|
||||
- Keep mutations in the provider or expose explicit actions
|
||||
- In TypeScript projects, prefer type-based `defineProps`, `defineEmits`, and `InjectionKey`
|
||||
|
||||
## Props: One-Way Data Down
|
||||
|
||||
Props are inputs. Do not mutate them in the child.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({ count: Number })
|
||||
|
||||
function increment() {
|
||||
props.count++
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
|
||||
If state needs to change, emit an event, use `v-model` or create a local copy.
|
||||
|
||||
## Prefer props/emit over component refs
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import UserForm from './UserForm.vue'
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
function submitForm() {
|
||||
if (formRef.value.isValid) {
|
||||
formRef.value.submit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserForm ref="formRef" />
|
||||
<button @click="submitForm">Submit</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import UserForm from './UserForm.vue'
|
||||
|
||||
function handleSubmit(formData) {
|
||||
api.submit(formData)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserForm @submit="handleSubmit" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Type component refs when imperative access is required
|
||||
|
||||
Prefer props/emits by default. When a parent must call an exposed child method, type the ref explicitly and expose only the intended API from the child with `defineExpose`.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import DialogPanel from './DialogPanel.vue'
|
||||
|
||||
const panelRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
panelRef.value.open()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPanel ref="panelRef" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- DialogPanel.vue -->
|
||||
<script setup lang="ts">
|
||||
function open() {}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Parent.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, useTemplateRef } from 'vue'
|
||||
import DialogPanel from './DialogPanel.vue'
|
||||
|
||||
// Vue 3.5+ with useTemplateRef
|
||||
const panelRef = useTemplateRef('panelRef')
|
||||
|
||||
// Before Vue 3.5 with manual typing and ref
|
||||
// const panelRef = ref<InstanceType<typeof DialogPanel> | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
panelRef.value?.open()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPanel ref="panelRef" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Emits: Explicit Events Up
|
||||
|
||||
Component events do not bubble. If a parent needs to know about an event, re-emit it explicitly.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<!-- Parent expects "saved" from grandchild, but it won't bubble -->
|
||||
<Child @saved="onSaved" />
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- Child.vue -->
|
||||
<script setup>
|
||||
const emit = defineEmits(['saved'])
|
||||
|
||||
function onGrandchildSaved(payload) {
|
||||
emit('saved', payload)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grandchild @saved="onGrandchildSaved" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Event naming:** use kebab-case in templates and camelCase in script:
|
||||
```vue
|
||||
<script setup>
|
||||
const emit = defineEmits(['updateUser'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileForm @update-user="emit('updateUser', $event)" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## `v-model`: Predictable Two-Way Bindings
|
||||
|
||||
Use `defineModel` by default for component bindings and emit updates on input. Only use the `modelValue` + `update:modelValue` pattern if you are on Vue < 3.4.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({ value: String })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input :value="props.value" @input="$emit('input', $event.target.value)" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD (Vue 3.4+):**
|
||||
```vue
|
||||
<script setup>
|
||||
const model = defineModel({ type: String })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-model="model" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD (Vue < 3.4):**
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({ modelValue: String })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:value="props.modelValue"
|
||||
@input="emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
If you need the updated value immediately after a change, use the input event value or `nextTick` in the parent.
|
||||
|
||||
## Provide/Inject: Shared Context Without Prop Drilling
|
||||
|
||||
Use provide/inject for cross-tree state, but keep mutations centralized in the provider and expose explicit actions.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
// Provider.vue
|
||||
provide('theme', reactive({ dark: false }))
|
||||
|
||||
// Consumer.vue
|
||||
const theme = inject('theme')
|
||||
// Mutating shared state from any depth becomes hard to track
|
||||
theme.dark = true
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
// Provider.vue
|
||||
const theme = reactive({ dark: false })
|
||||
const toggleTheme = () => { theme.dark = !theme.dark }
|
||||
|
||||
provide(themeKey, readonly(theme))
|
||||
provide(themeActionsKey, { toggleTheme })
|
||||
|
||||
// Consumer.vue
|
||||
const theme = inject(themeKey)
|
||||
const { toggleTheme } = inject(themeActionsKey)
|
||||
```
|
||||
|
||||
Use symbols for keys to avoid collisions in large apps:
|
||||
```ts
|
||||
export const themeKey = Symbol('theme')
|
||||
export const themeActionsKey = Symbol('theme-actions')
|
||||
```
|
||||
|
||||
## Use TypeScript Contracts for Public Component APIs
|
||||
|
||||
In TypeScript projects, type component boundaries directly with `defineProps`, `defineEmits`, and `InjectionKey` so invalid payloads and mismatched injections fail at compile time.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
userId: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save'])
|
||||
const settings = inject('settings')
|
||||
|
||||
// Payload shape is not checked here
|
||||
emit('save', 123)
|
||||
|
||||
// Key is string-based and not type-safe
|
||||
settings?.theme = 'dark'
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject, provide } from 'vue'
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
interface Props {
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
save: [payload: { id: string; draft: boolean }]
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
const settingsKey: InjectionKey<Settings> = Symbol('settings')
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
provide(settingsKey, { theme: 'light' })
|
||||
|
||||
const settings = inject(settingsKey)
|
||||
if (settings) {
|
||||
emit('save', { id: props.userId, draft: false })
|
||||
}
|
||||
</script>
|
||||
```
|
||||
@@ -1,174 +0,0 @@
|
||||
---
|
||||
title: Component Fallthrough Attributes Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Incorrect $attrs access and reactivity assumptions can cause undefined values and watchers that never run
|
||||
type: best-practice
|
||||
tags: [vue3, attrs, fallthrough-attributes, composition-api, reactivity]
|
||||
---
|
||||
|
||||
# Component Fallthrough Attributes Best Practices
|
||||
|
||||
**Impact: MEDIUM** - Fallthrough attributes are straightforward once you follow Vue's conventions: hyphenated names use bracket notation, listener keys are camelCase `onX`, and `useAttrs()` is current-but-not-reactive.
|
||||
|
||||
## Task List
|
||||
|
||||
- Access hyphenated attribute names with bracket notation (for example `attrs['data-testid']`)
|
||||
- Access event listeners with camelCase `onX` keys (for example `attrs.onClick`)
|
||||
- Do not `watch()` values returned from `useAttrs()`; those watchers do not trigger on attr changes
|
||||
- Use `onUpdated()` for attr-driven side effects
|
||||
- Promote frequently observed attrs to props when reactive observation is required
|
||||
|
||||
## Access Attribute and Listener Keys Correctly
|
||||
|
||||
Hyphenated attribute names preserve their original casing in JavaScript, so dot notation does not work for keys that include `-`.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
console.log(attrs.data-testid) // Syntax error
|
||||
console.log(attrs.dataTestid) // undefined for data-testid
|
||||
console.log(attrs['on-click']) // undefined
|
||||
console.log(attrs['@click']) // undefined
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
console.log(attrs['data-testid'])
|
||||
console.log(attrs['aria-label'])
|
||||
console.log(attrs['foo-bar'])
|
||||
|
||||
console.log(attrs.onClick)
|
||||
console.log(attrs.onCustomEvent)
|
||||
console.log(attrs.onMouseEnter)
|
||||
</script>
|
||||
```
|
||||
|
||||
### Naming Reference
|
||||
|
||||
| Parent Usage | Access in `attrs` |
|
||||
|--------------|-------------------|
|
||||
| `class="foo"` | `attrs.class` |
|
||||
| `data-id="123"` | `attrs['data-id']` |
|
||||
| `aria-label="..."` | `attrs['aria-label']` |
|
||||
| `foo-bar="baz"` | `attrs['foo-bar']` |
|
||||
| `@click="fn"` | `attrs.onClick` |
|
||||
| `@custom-event="fn"` | `attrs.onCustomEvent` |
|
||||
| `@update:modelValue="fn"` | `attrs['onUpdate:modelValue']` |
|
||||
|
||||
## `useAttrs()` Is Not Reactive
|
||||
|
||||
`useAttrs()` always reflects the latest values, but it is intentionally not reactive for watcher tracking.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch, watchEffect, useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
watch(
|
||||
() => attrs.someAttr,
|
||||
(newValue) => {
|
||||
console.log('Changed:', newValue) // Never runs on attr changes
|
||||
}
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
console.log(attrs.class) // Runs on setup, not on attr updates
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { onUpdated, useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
onUpdated(() => {
|
||||
console.log('Latest attrs:', attrs)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
someAttr: String
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.someAttr,
|
||||
(newValue) => {
|
||||
console.log('Changed:', newValue)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Check for optional attrs safely
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const hasTestId = computed(() => 'data-testid' in attrs)
|
||||
const ariaLabel = computed(() => attrs['aria-label'] ?? 'Default label')
|
||||
</script>
|
||||
```
|
||||
|
||||
### Forward listeners after internal logic
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
function handleClick(event) {
|
||||
console.log('Internal handling first')
|
||||
attrs.onClick?.(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="handleClick">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## TypeScript Notes
|
||||
|
||||
`useAttrs()` is typed as `Record<string, unknown>`, so cast individual keys when needed.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const testId = attrs['data-testid'] as string | undefined
|
||||
const onClick = attrs.onClick as ((event: MouseEvent) => void) | undefined
|
||||
</script>
|
||||
```
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
title: KeepAlive Component Best Practices
|
||||
impact: HIGH
|
||||
impactDescription: KeepAlive caches component instances; misuse causes stale data, memory growth, or unexpected lifecycle behavior
|
||||
type: best-practice
|
||||
tags: [vue3, keepalive, cache, performance, router, dynamic-components]
|
||||
---
|
||||
|
||||
# KeepAlive Component Best Practices
|
||||
|
||||
**Impact: HIGH** - `<KeepAlive>` caches component instances instead of destroying them. Use it to preserve state across switches, but manage cache size and freshness explicitly to avoid memory growth or stale UI.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use KeepAlive only where state preservation improves UX
|
||||
- Set a reasonable `max` to cap cache size
|
||||
- Declare component names for include/exclude matching
|
||||
- Use `onActivated`/`onDeactivated` for cache-aware logic
|
||||
- Decide how and when cached views refresh their data
|
||||
- Avoid caching memory-heavy or security-sensitive views
|
||||
|
||||
## When to Use KeepAlive
|
||||
|
||||
Use KeepAlive when switching between views where state should persist (tabs, multi-step forms, dashboards). Avoid it when each visit should start fresh.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- State resets on every switch -->
|
||||
<component :is="currentTab" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- State preserved between switches -->
|
||||
<KeepAlive>
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When NOT to Use KeepAlive
|
||||
|
||||
- Search or filter pages where users expect fresh results
|
||||
- Memory-heavy components (maps, large tables, media players)
|
||||
- Sensitive flows where data must be cleared on exit
|
||||
- Components with heavy background activity you cannot pause
|
||||
|
||||
## Limit and Control the Cache
|
||||
|
||||
Always cap cache size with `max` and restrict caching to specific components when possible.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<KeepAlive :max="5" include="Dashboard,Settings">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Ensure Component Names Match include/exclude
|
||||
|
||||
`include` and `exclude` match the component `name` option. Explicitly set names for reliable caching.
|
||||
|
||||
```vue
|
||||
<!-- TabA.vue -->
|
||||
<script setup>
|
||||
defineOptions({ name: 'TabA' })
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<KeepAlive include="TabA,TabB">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Cache Invalidation Strategies
|
||||
|
||||
Vue 3 has no direct API to remove a specific cached instance. Use keys or dynamic include/exclude to force refreshes.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const currentView = ref('Dashboard')
|
||||
const viewKeys = reactive({ Dashboard: 0, Settings: 0 })
|
||||
|
||||
function invalidateCache(view) {
|
||||
viewKeys[view]++
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KeepAlive>
|
||||
<component :is="currentView" :key="`${currentView}-${viewKeys[currentView]}`" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Lifecycle Hooks for Cached Components
|
||||
|
||||
Cached components are not destroyed on switch. Use activation hooks for refresh and cleanup.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { onActivated, onDeactivated } from 'vue'
|
||||
|
||||
onActivated(() => {
|
||||
refreshData()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
pauseTimers()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Router Caching and Freshness
|
||||
|
||||
Decide whether navigation should show cached state or a fresh view. A common pattern is to key by route when params change.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<KeepAlive>
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
If you want cache reuse but fresh data, refresh in `onActivated` and compare query/params before fetching.
|
||||
@@ -1,216 +0,0 @@
|
||||
---
|
||||
title: Component Slots Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Poor slot API design causes empty DOM wrappers, weak TypeScript safety, brittle defaults, and unnecessary component overhead
|
||||
type: best-practice
|
||||
tags: [vue3, slots, components, typescript, composables]
|
||||
---
|
||||
|
||||
# Component Slots Best Practices
|
||||
|
||||
**Impact: MEDIUM** - Slots are a core component API surface in Vue. Structure them intentionally so templates stay predictable, typed, and performant.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use shorthand syntax for named slots (`#` instead of `v-slot:`)
|
||||
- Render optional slot wrapper elements only when slot content exists (`$slots` checks)
|
||||
- Type scoped slot contracts with `defineSlots` in TypeScript components
|
||||
- Provide fallback content for optional slots
|
||||
- Prefer composables over renderless components for pure logic reuse
|
||||
|
||||
## Shorthand syntax for named slots
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<MyComponent>
|
||||
<template v-slot:header> ... </template>
|
||||
</MyComponent>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<MyComponent>
|
||||
<template #header> ... </template>
|
||||
</MyComponent>
|
||||
```
|
||||
|
||||
## Conditionally Render Optional Slot Wrappers
|
||||
|
||||
Use `$slots` checks when wrapper elements add spacing, borders, or layout constraints.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<!-- Card.vue -->
|
||||
<template>
|
||||
<article class="card">
|
||||
<header class="card-header">
|
||||
<slot name="header" />
|
||||
</header>
|
||||
|
||||
<section class="card-body">
|
||||
<slot />
|
||||
</section>
|
||||
|
||||
<footer class="card-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- Card.vue -->
|
||||
<template>
|
||||
<article class="card">
|
||||
<header v-if="$slots.header" class="card-header">
|
||||
<slot name="header" />
|
||||
</header>
|
||||
|
||||
<section v-if="$slots.default" class="card-body">
|
||||
<slot />
|
||||
</section>
|
||||
|
||||
<footer v-if="$slots.footer" class="card-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Type Scoped Slot Props with defineSlots
|
||||
|
||||
In `<script setup lang="ts">`, use `defineSlots` so slot consumers get autocomplete and static checks.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<!-- ProductList.vue -->
|
||||
<script setup lang="ts">
|
||||
interface Product {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
defineProps<{ products: Product[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
<li v-for="(product, index) in products" :key="product.id">
|
||||
<slot :product="product" :index="index" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- ProductList.vue -->
|
||||
<script setup lang="ts">
|
||||
interface Product {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
defineProps<{ products: Product[] }>()
|
||||
|
||||
defineSlots<{
|
||||
default(props: { product: Product; index: number }): any
|
||||
empty(): any
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul v-if="products.length">
|
||||
<li v-for="(product, index) in products" :key="product.id">
|
||||
<slot :product="product" :index="index" />
|
||||
</li>
|
||||
</ul>
|
||||
<slot v-else name="empty" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Provide Slot Fallback Content
|
||||
|
||||
Fallback content makes components resilient when parents omit optional slots.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<!-- SubmitButton.vue -->
|
||||
<template>
|
||||
<button type="submit" class="btn-primary">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- SubmitButton.vue -->
|
||||
<template>
|
||||
<button type="submit" class="btn-primary">
|
||||
<slot>Submit</slot>
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Prefer Composables for Pure Logic Reuse
|
||||
|
||||
Renderless components are still useful for slot-driven composition, but composables are usually cleaner for logic-only reuse.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<!-- MouseTracker.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
function onMove(event: MouseEvent) {
|
||||
x.value = event.pageX
|
||||
y.value = event.pageY
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('mousemove', onMove))
|
||||
onUnmounted(() => window.removeEventListener('mousemove', onMove))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot :x="x" :y="y" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
// composables/useMouse.ts
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useMouse() {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
function onMove(event: MouseEvent) {
|
||||
x.value = event.pageX
|
||||
y.value = event.pageY
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('mousemove', onMove))
|
||||
onUnmounted(() => window.removeEventListener('mousemove', onMove))
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- MousePosition.vue -->
|
||||
<script setup lang="ts">
|
||||
import { useMouse } from '@/composables/useMouse'
|
||||
|
||||
const { x, y } = useMouse()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p>{{ x }}, {{ y }}</p>
|
||||
</template>
|
||||
```
|
||||
@@ -1,228 +0,0 @@
|
||||
---
|
||||
title: Suspense Component Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Suspense coordinates async dependencies with fallback UI; misconfiguration leads to missing loading states or confusing UX
|
||||
type: best-practice
|
||||
tags: [vue3, suspense, async-components, async-setup, loading, fallback, router, transition, keepalive]
|
||||
---
|
||||
|
||||
# Suspense Component Best Practices
|
||||
|
||||
**Impact: MEDIUM** - `<Suspense>` coordinates async dependencies (async components or async setup) and renders a fallback while they resolve. Misconfiguration leads to missing loading states, empty renders, or subtle UX bugs.
|
||||
|
||||
## Task List
|
||||
|
||||
- Wrap default and fallback slot content in a single root node
|
||||
- Use `timeout` when you need the fallback to appear on reverts
|
||||
- Force root replacement with `:key` when you need Suspense to re-trigger
|
||||
- Add `suspensible` to nested Suspense boundaries (Vue 3.3+)
|
||||
- Use `@pending`, `@resolve`, and `@fallback` for programmatic loading state
|
||||
- Nest `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` in that order
|
||||
- Keep Suspense usage centralized and documented in production
|
||||
|
||||
## Single Root in Default and Fallback Slots
|
||||
|
||||
Suspense tracks a single immediate child in both slots. Wrap multiple elements in a single element or component.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<AsyncHeader />
|
||||
<AsyncList />
|
||||
|
||||
<template #fallback>
|
||||
<LoadingSpinner />
|
||||
<LoadingHint />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<div>
|
||||
<AsyncHeader />
|
||||
<AsyncList />
|
||||
</div>
|
||||
|
||||
<template #fallback>
|
||||
<div>
|
||||
<LoadingSpinner />
|
||||
<LoadingHint />
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Fallback Timing on Reverts (`timeout`)
|
||||
|
||||
When Suspense is already resolved and new async work starts, the previous content remains visible until the timeout elapses. Use `timeout="0"` for immediate fallback or a short delay to avoid flicker.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<component :is="currentView" :key="viewKey" />
|
||||
|
||||
<template #fallback>
|
||||
Loading...
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense :timeout="200">
|
||||
<component :is="currentView" :key="viewKey" />
|
||||
|
||||
<template #fallback>
|
||||
Loading...
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Pending State Only Re-triggers on Root Replacement
|
||||
|
||||
Once resolved, Suspense only re-enters pending when the root node of the default slot changes. If async work happens deeper in the tree, no fallback appears.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<TabContainer>
|
||||
<AsyncDashboard v-if="tab === 'dashboard'" />
|
||||
<AsyncSettings v-else />
|
||||
</TabContainer>
|
||||
|
||||
<template #fallback>
|
||||
Loading...
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<component :is="tabs[tab]" :key="tab" />
|
||||
|
||||
<template #fallback>
|
||||
Loading...
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Use `suspensible` for Nested Suspense (Vue 3.3+)
|
||||
|
||||
Nested Suspense boundaries need `suspensible` on the inner boundary so the parent can coordinate loading state. Without it, inner async content may render empty nodes until resolved.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<LayoutShell>
|
||||
<Suspense>
|
||||
<AsyncWidget />
|
||||
<template #fallback>Loading widget...</template>
|
||||
</Suspense>
|
||||
</LayoutShell>
|
||||
|
||||
<template #fallback>Loading layout...</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<LayoutShell>
|
||||
<Suspense suspensible>
|
||||
<AsyncWidget />
|
||||
<template #fallback>Loading widget...</template>
|
||||
</Suspense>
|
||||
</LayoutShell>
|
||||
|
||||
<template #fallback>Loading layout...</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Track Loading with Suspense Events
|
||||
|
||||
Use `@pending`, `@resolve`, and `@fallback` for analytics, global loading indicators, or coordinating UI outside the Suspense boundary.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const onPending = () => {
|
||||
isLoading.value = true
|
||||
}
|
||||
|
||||
const onResolve = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingBar v-if="isLoading" />
|
||||
|
||||
<Suspense @pending="onPending" @resolve="onResolve">
|
||||
<AsyncPage />
|
||||
<template #fallback>
|
||||
<PageSkeleton />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Recommended Nesting with RouterView, Transition, KeepAlive
|
||||
|
||||
When combining these components, the nesting order should be `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` so each wrapper works correctly.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Suspense>
|
||||
<KeepAlive>
|
||||
<Transition mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</KeepAlive>
|
||||
</Suspense>
|
||||
</RouterView>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition mode="out-in">
|
||||
<KeepAlive>
|
||||
<Suspense>
|
||||
<component :is="Component" />
|
||||
<template #fallback>Loading...</template>
|
||||
</Suspense>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Treat Suspense Cautiously in Production
|
||||
|
||||
In production code, keep Suspense boundaries minimal, document where they are used, and have a fallback loading strategy if you ever need to replace or refactor them.
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
title: Teleport Component Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Teleport renders content outside the component's DOM position, which is essential for overlays but affects styling and layout
|
||||
type: best-practice
|
||||
tags: [vue3, teleport, modal, overlay, positioning, responsive]
|
||||
---
|
||||
|
||||
# Teleport Component Best Practices
|
||||
|
||||
**Impact: MEDIUM** - `<Teleport>` renders part of a component's template in a different place in the DOM while preserving the Vue component hierarchy. Use it for overlays (modals, toasts, tooltips) or any UI that must escape stacking contexts, overflow, or fixed positioning constraints.
|
||||
|
||||
## Task List
|
||||
|
||||
- Teleport overlays to `body` or a dedicated container outside the app root
|
||||
- Keep a shared target for similar UI (`#modals`, `#notifications`) and control layering with order or z-index
|
||||
- Use `:disabled` for responsive layouts that should render inline on small screens
|
||||
- Remember props, emits, and provide/inject still work through teleport
|
||||
- Avoid relying on parent stacking contexts or transforms for teleported UI
|
||||
|
||||
## Teleport Overlays Out of Transformed Containers
|
||||
|
||||
When an ancestor has `transform`, `filter`, or `perspective`, fixed-position overlays can behave like they are locally positioned. Teleport escapes that context.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<div class="animated-container">
|
||||
<button @click="open = true">Open</button>
|
||||
|
||||
<!-- Broken: fixed positioning is scoped to the transformed parent -->
|
||||
<div v-if="open" class="modal">Modal</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.animated-container {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<div class="animated-container">
|
||||
<button @click="open = true">Open</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal">Modal</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Responsive Layouts with `disabled`
|
||||
|
||||
Use `:disabled` to render inline on mobile and teleport on larger screens:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
|
||||
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body" :disabled="isMobile">
|
||||
<nav class="sidebar">Navigation</nav>
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Logical Hierarchy Is Preserved
|
||||
|
||||
Teleport changes DOM position, not the Vue component tree. Props, emits, slots, and provide/inject still work:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<ChildPanel :message="message" @close="open = false" />
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Multiple Teleports to the Same Target
|
||||
|
||||
Teleports to the same target append in declaration order:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Teleport to="#notifications">
|
||||
<div>First</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="#notifications">
|
||||
<div>Second</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
Use a shared container to keep stacking predictable, and apply z-index only when you need explicit layering.
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
title: TransitionGroup Component Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: TransitionGroup animates list items; missing keys or misuse leads to broken list transitions
|
||||
type: best-practice
|
||||
tags: [vue3, transition-group, animation, lists, keys]
|
||||
---
|
||||
|
||||
# TransitionGroup Component Best Practices
|
||||
|
||||
**Impact: MEDIUM** - `<TransitionGroup>` animates lists of items entering, leaving, and moving. Use it for `v-for` lists or dynamic collections where individual items change over time.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use `<TransitionGroup>` only for lists and repeated items
|
||||
- Provide unique, stable keys for every direct child
|
||||
- Use `tag` when you need semantic or layout wrappers
|
||||
- Avoid the `mode` prop (not supported)
|
||||
- Use JavaScript hooks for staggered effects
|
||||
|
||||
## Use TransitionGroup for Lists
|
||||
|
||||
`<TransitionGroup>` is designed for list items. Use `tag` to control the wrapper element when needed.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<TransitionGroup name="fade">
|
||||
<ComponentA />
|
||||
<ComponentB />
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<TransitionGroup name="list" tag="ul">
|
||||
<li v-for="item in items" :key="item.id">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Always Provide Stable Keys
|
||||
|
||||
Keys are required. Without stable keys, Vue cannot track item positions and animations break.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<TransitionGroup name="list" tag="ul">
|
||||
<li v-for="(item, index) in items" :key="index">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<TransitionGroup name="list" tag="ul">
|
||||
<li v-for="item in items" :key="item.id">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Do Not Use `mode` on TransitionGroup
|
||||
|
||||
`mode` is only for `<Transition>` because it swaps a single element. Use `<Transition>` if you need in/out sequencing.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<TransitionGroup name="list" tag="div" mode="out-in">
|
||||
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<component :is="currentView" :key="currentView" />
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Stagger List Animations with Data Attributes
|
||||
|
||||
For cascading list animations, pass the index to JavaScript hooks and compute delay per item.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
:css="false"
|
||||
@before-enter="onBeforeEnter"
|
||||
@enter="onEnter"
|
||||
>
|
||||
<li v-for="(item, index) in items" :key="item.id" :data-index="index">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function onBeforeEnter(el) {
|
||||
el.style.opacity = 0
|
||||
el.style.transform = 'translateY(12px)'
|
||||
}
|
||||
|
||||
function onEnter(el, done) {
|
||||
const delay = Number(el.dataset.index) * 80
|
||||
setTimeout(() => {
|
||||
el.style.transition = 'all 0.25s ease'
|
||||
el.style.opacity = 1
|
||||
el.style.transform = 'translateY(0)'
|
||||
setTimeout(done, 250)
|
||||
}, delay)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
title: Transition Component Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Transition animates a single element or component; incorrect structure or keys prevent animations
|
||||
type: best-practice
|
||||
tags: [vue3, transition, animation, performance, keys]
|
||||
---
|
||||
|
||||
# Transition Component Best Practices
|
||||
|
||||
**Impact: MEDIUM** - `<Transition>` animates entering/leaving of a single element or component. It is ideal for toggling UI states, swapping views, or animating one component at a time.
|
||||
|
||||
## Task List
|
||||
|
||||
- Wrap a single element or component inside `<Transition>`
|
||||
- Provide a `key` when switching between same element types
|
||||
- Use `mode="out-in"` when you need sequential swaps
|
||||
- Prefer `transform` and `opacity` for smooth animations
|
||||
|
||||
## Use Transition for a Single Root Element
|
||||
|
||||
`<Transition>` only supports one direct child. Wrap multiple nodes in a single element or component.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<h3>Title</h3>
|
||||
<p>Description</p>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div>
|
||||
<h3>Title</h3>
|
||||
<p>Description</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Force Transitions Between Same Element Types
|
||||
|
||||
Vue reuses the same DOM element when the tag type does not change. Add `key` so Vue treats it as a new element and triggers enter/leave.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<p v-if="isActive">Active</p>
|
||||
<p v-else>Inactive</p>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<p v-if="isActive" key="active">Active</p>
|
||||
<p v-else key="inactive">Inactive</p>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Use `mode` to Avoid Overlap During Swaps
|
||||
|
||||
When swapping components or views, use `mode="out-in"` to prevent both from being visible at the same time.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<component :is="currentView" />
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<component :is="currentView" :key="currentView" />
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Animate `transform` and `opacity` for Performance
|
||||
|
||||
Avoid layout-triggering properties such as `height`, `margin`, or `top`. Use `transform` and `opacity` for smooth, GPU-friendly transitions.
|
||||
|
||||
**BAD:**
|
||||
```css
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
height: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```css
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
transform: translateX(-12px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
transform: translateX(12px);
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
@@ -1,290 +0,0 @@
|
||||
---
|
||||
title: Composable Organization Patterns
|
||||
impact: MEDIUM
|
||||
impactDescription: Well-structured composables improve maintainability, reusability, and update performance
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, code-organization, api-design, readonly, utilities]
|
||||
---
|
||||
|
||||
# Composable Organization Patterns
|
||||
|
||||
**Impact: MEDIUM** - Treat composables as reusable, stateful building blocks and keep their code organized by feature concern. This keeps large components maintainable and prevents hard-to-debug mutation and API design issues.
|
||||
|
||||
## Task List
|
||||
|
||||
- Compose complex behavior from small, focused composables
|
||||
- Use options objects for composables with multiple optional parameters
|
||||
- Return readonly state when updates must flow through explicit actions
|
||||
- Keep pure utility functions as plain utilities, not composables
|
||||
- Organize composable and component code by feature concern, and extract composables when components grow
|
||||
|
||||
## Compose Composables from Smaller Primitives
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
const inside = ref(false)
|
||||
const el = ref(null)
|
||||
|
||||
function onMove(e) {
|
||||
x.value = e.pageX
|
||||
y.value = e.pageY
|
||||
if (!el.value) return
|
||||
const r = el.value.getBoundingClientRect()
|
||||
inside.value = x.value >= r.left && x.value <= r.right &&
|
||||
y.value >= r.top && y.value <= r.bottom
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('mousemove', onMove))
|
||||
onUnmounted(() => window.removeEventListener('mousemove', onMove))
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
// composables/useEventListener.js
|
||||
import { onMounted, onUnmounted, toValue } from 'vue'
|
||||
|
||||
export function useEventListener(target, event, callback) {
|
||||
onMounted(() => toValue(target).addEventListener(event, callback))
|
||||
onUnmounted(() => toValue(target).removeEventListener(event, callback))
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useMouse.js
|
||||
import { ref } from 'vue'
|
||||
import { useEventListener } from './useEventListener'
|
||||
|
||||
export function useMouse() {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
useEventListener(window, 'mousemove', (e) => {
|
||||
x.value = e.pageX
|
||||
y.value = e.pageY
|
||||
})
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useMouseInElement.js
|
||||
import { computed } from 'vue'
|
||||
import { useMouse } from './useMouse'
|
||||
|
||||
export function useMouseInElement(elementRef) {
|
||||
const { x, y } = useMouse()
|
||||
|
||||
const isOutside = computed(() => {
|
||||
if (!elementRef.value) return true
|
||||
const rect = elementRef.value.getBoundingClientRect()
|
||||
return x.value < rect.left || x.value > rect.right ||
|
||||
y.value < rect.top || y.value > rect.bottom
|
||||
})
|
||||
|
||||
return { x, y, isOutside }
|
||||
}
|
||||
```
|
||||
|
||||
## Use Options Object Pattern for Composable Parameters
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
export function useFetch(url, method, headers, timeout, retries, immediate) {
|
||||
// hard to read and easy to misorder
|
||||
}
|
||||
|
||||
useFetch('/api/users', 'GET', null, 5000, 3, true)
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
export function useFetch(url, options = {}) {
|
||||
const {
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
timeout = 30000,
|
||||
retries = 0,
|
||||
immediate = true
|
||||
} = options
|
||||
|
||||
// implementation
|
||||
return { method, headers, timeout, retries, immediate }
|
||||
}
|
||||
|
||||
useFetch('/api/users', {
|
||||
method: 'POST',
|
||||
timeout: 5000,
|
||||
retries: 3
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
interface UseCounterOptions {
|
||||
initial?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}
|
||||
|
||||
export function useCounter(options: UseCounterOptions = {}) {
|
||||
const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options
|
||||
// implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Return Readonly State with Explicit Actions
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
export function useCart() {
|
||||
const items = ref([])
|
||||
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
|
||||
return { items, total } // any consumer can mutate directly
|
||||
}
|
||||
|
||||
const { items } = useCart()
|
||||
items.value.push({ id: 1, price: 10 })
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
export function useCart() {
|
||||
const _items = ref([])
|
||||
|
||||
const total = computed(() =>
|
||||
_items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
)
|
||||
|
||||
function addItem(product, quantity = 1) {
|
||||
const existing = _items.value.find(item => item.id === product.id)
|
||||
if (existing) {
|
||||
existing.quantity += quantity
|
||||
return
|
||||
}
|
||||
_items.value.push({ ...product, quantity })
|
||||
}
|
||||
|
||||
function removeItem(productId) {
|
||||
_items.value = _items.value.filter(item => item.id !== productId)
|
||||
}
|
||||
|
||||
return {
|
||||
items: readonly(_items),
|
||||
total,
|
||||
addItem,
|
||||
removeItem
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keep Utilities as Utilities
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
export function useFormatters() {
|
||||
const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date)
|
||||
const formatCurrency = (amount) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
|
||||
return { formatDate, formatCurrency }
|
||||
}
|
||||
|
||||
const { formatDate } = useFormatters()
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
// utils/formatters.js
|
||||
export function formatDate(date) {
|
||||
return new Intl.DateTimeFormat('en-US').format(date)
|
||||
}
|
||||
|
||||
export function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useInvoiceSummary.js
|
||||
import { computed } from 'vue'
|
||||
import { formatCurrency } from '@/utils/formatters'
|
||||
|
||||
export function useInvoiceSummary(invoiceRef) {
|
||||
const totalLabel = computed(() => formatCurrency(invoiceRef.value.total))
|
||||
return { totalLabel }
|
||||
}
|
||||
```
|
||||
|
||||
## Organize Composable and Component Code by Feature Concern
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const items = ref([])
|
||||
const selected = ref(null)
|
||||
const showModal = ref(false)
|
||||
const sortBy = ref('name')
|
||||
const filter = ref('all')
|
||||
const loading = ref(false)
|
||||
|
||||
const filtered = computed(() => items.value.filter(i => i.category === filter.value))
|
||||
function openModal() { showModal.value = true }
|
||||
const sorted = computed(() => [...filtered.value].sort(/* ... */))
|
||||
watch(searchQuery, () => { /* ... */ })
|
||||
onMounted(() => { /* ... */ })
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { useItems } from '@/composables/useItems'
|
||||
import { useSearch } from '@/composables/useSearch'
|
||||
import { useSelectionModal } from '@/composables/useSelectionModal'
|
||||
|
||||
// Data
|
||||
const { items, loading, fetchItems } = useItems()
|
||||
|
||||
// Search/filter/sort
|
||||
const { query, visibleItems } = useSearch(items)
|
||||
|
||||
// Selection + modal
|
||||
const { selectedItem, isModalOpen, selectItem, closeModal } = useSelectionModal()
|
||||
</script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useItems.js
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
export function useItems() {
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await api.getItems()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchItems)
|
||||
return { items, loading, fetchItems }
|
||||
}
|
||||
```
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
title: Directive Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Custom directives are powerful but easy to misuse; following patterns prevents leaks, invalid usage, and unclear abstractions
|
||||
type: best-practice
|
||||
tags: [vue3, directives, custom-directives, composition, typescript]
|
||||
---
|
||||
|
||||
# Directive Best Practices
|
||||
|
||||
**Impact: MEDIUM** - Directives are for low-level DOM access. Use them sparingly, keep them side-effect safe, and prefer components or composables when you need stateful or reusable UI behavior.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use directives only when you need direct DOM access
|
||||
- Do not mutate directive arguments or binding objects
|
||||
- Clean up timers, listeners, and observers in `unmounted`
|
||||
- Register directives in `<script setup>` with the `v-` prefix
|
||||
- In TypeScript projects, type directive values and augment template directive types
|
||||
- Prefer components or composables for complex behavior
|
||||
|
||||
## Treat Directive Arguments as Read-Only
|
||||
|
||||
Directive bindings are not reactive storage. Don’t write to them.
|
||||
|
||||
```ts
|
||||
const vFocus = {
|
||||
mounted(el, binding) {
|
||||
// binding.value is read-only
|
||||
el.focus()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Avoid Directives on Components
|
||||
|
||||
Directives apply to DOM elements. When used on components, they attach to the root element and can break if the root changes.
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<MyInput v-focus />
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- MyInput.vue -->
|
||||
<script setup>
|
||||
const vFocus = (el) => el.focus()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-focus />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Clean Up Side Effects in `unmounted`
|
||||
|
||||
Any timers, listeners, or observers must be removed to avoid leaks.
|
||||
|
||||
```ts
|
||||
const vResize = {
|
||||
mounted(el) {
|
||||
const observer = new ResizeObserver(() => {})
|
||||
observer.observe(el)
|
||||
el._observer = observer
|
||||
},
|
||||
unmounted(el) {
|
||||
el._observer?.disconnect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prefer Function Shorthand for Single-Hook Directives
|
||||
|
||||
If you only need `mounted`/`updated`, use the function form.
|
||||
|
||||
```ts
|
||||
const vAutofocus = (el) => el.focus()
|
||||
```
|
||||
|
||||
## Use the `v-` Prefix and Script Setup Registration
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const vFocus = (el) => el.focus()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-focus />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Type Custom Directives in TypeScript Projects
|
||||
|
||||
Use `Directive<Element, ValueType>` so `binding.value` is typed, and augment Vue's template types so directives are recognized in SFC templates.
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
// Untyped directive value and no template type augmentation
|
||||
export const vHighlight = {
|
||||
mounted(el, binding) {
|
||||
el.style.backgroundColor = binding.value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
type HighlightValue = string
|
||||
|
||||
export const vHighlight = {
|
||||
mounted(el, binding) {
|
||||
el.style.backgroundColor = binding.value
|
||||
}
|
||||
} satisfies Directive<HTMLElement, HighlightValue>
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
vHighlight: typeof vHighlight
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Handle SSR with `getSSRProps`
|
||||
|
||||
Directive hooks such as `mounted` and `updated` do not run during SSR. If a directive sets attributes/classes that affect rendered HTML, provide an SSR equivalent via `getSSRProps` to avoid hydration mismatches.
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
const vTooltip = {
|
||||
mounted(el, binding) {
|
||||
el.setAttribute('data-tooltip', binding.value)
|
||||
el.classList.add('has-tooltip')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
const vTooltip = {
|
||||
mounted(el, binding) {
|
||||
el.setAttribute('data-tooltip', binding.value)
|
||||
el.classList.add('has-tooltip')
|
||||
},
|
||||
getSSRProps(binding) {
|
||||
return {
|
||||
'data-tooltip': binding.value,
|
||||
class: 'has-tooltip'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prefer Declarative Templates When Possible
|
||||
|
||||
If a standard attribute or binding works, use it instead of a directive.
|
||||
|
||||
## Decide Between Directives and Components
|
||||
|
||||
Use a directive for DOM-level behavior. Use a component when behavior affects structure, state, or rendering.
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
---
|
||||
title: Avoid Excessive Component Abstraction in Large Lists
|
||||
impact: MEDIUM
|
||||
impactDescription: Each component instance has memory and render overhead - abstractions multiply this in lists
|
||||
type: efficiency
|
||||
tags: [vue3, performance, components, abstraction, lists, optimization]
|
||||
---
|
||||
|
||||
# Avoid Excessive Component Abstraction in Large Lists
|
||||
|
||||
**Impact: MEDIUM** - Component instances are more expensive than plain DOM nodes. While abstractions improve code organization, unnecessary nesting creates overhead. In large lists, this overhead multiplies - 100 items with 3 levels of abstraction means 300+ component instances instead of 100.
|
||||
|
||||
Don't avoid abstraction entirely, but be mindful of component depth in frequently-rendered elements like list items.
|
||||
|
||||
## Task List
|
||||
|
||||
- Review list item components for unnecessary wrapper components
|
||||
- Consider flattening component hierarchies in hot paths
|
||||
- Use native elements when a component adds no value
|
||||
- Profile component counts using Vue DevTools
|
||||
- Focus optimization efforts on the most-rendered components
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<!-- BAD: Deep abstraction in list items -->
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<!-- For 100 users: Creates 400 component instances -->
|
||||
<UserCard v-for="user in users" :key="user.id" :user="user" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- UserCard.vue -->
|
||||
<template>
|
||||
<Card> <!-- Wrapper component #1 -->
|
||||
<CardHeader> <!-- Wrapper component #2 -->
|
||||
<UserAvatar :src="user.avatar" /> <!-- Wrapper component #3 -->
|
||||
</CardHeader>
|
||||
<CardBody> <!-- Wrapper component #4 -->
|
||||
<Text>{{ user.name }}</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- Each UserCard creates: Card + CardHeader + CardBody + UserAvatar + Text
|
||||
100 users = 500+ component instances -->
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- GOOD: Flattened structure in list items -->
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<!-- For 100 users: Creates 100 component instances -->
|
||||
<UserCard v-for="user in users" :key="user.id" :user="user" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- UserCard.vue - Flattened, uses native elements -->
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<img :src="user.avatar" :alt="user.name" class="avatar" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span class="user-name">{{ user.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
user: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Styles that would have been in Card, CardHeader, etc. */
|
||||
.card { /* ... */ }
|
||||
.card-header { /* ... */ }
|
||||
.card-body { /* ... */ }
|
||||
.avatar { /* ... */ }
|
||||
</style>
|
||||
```
|
||||
|
||||
## When Abstraction Is Still Worth It
|
||||
|
||||
```vue
|
||||
<!-- Component abstraction is valuable when: -->
|
||||
|
||||
<!-- 1. Complex behavior is encapsulated -->
|
||||
<UserStatusIndicator :user="user" /> <!-- Has logic, tooltips, etc. -->
|
||||
|
||||
<!-- 2. Reused outside of the hot path -->
|
||||
<Card> <!-- OK to use in one-off places, not in 100-item lists -->
|
||||
|
||||
<!-- 3. The list itself is small -->
|
||||
<template v-if="items.length < 20">
|
||||
<FancyItem v-for="item in items" :key="item.id" />
|
||||
</template>
|
||||
|
||||
<!-- 4. Virtualization is used (only ~20 items rendered at once) -->
|
||||
<RecycleScroller :items="items">
|
||||
<template #default="{ item }">
|
||||
<ComplexItem :item="item" /> <!-- OK - only 20 instances exist -->
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
```
|
||||
|
||||
## Measuring Component Overhead
|
||||
|
||||
```javascript
|
||||
// In development, profile component counts
|
||||
import { onMounted, getCurrentInstance } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
const instance = getCurrentInstance()
|
||||
let count = 0
|
||||
|
||||
function countComponents(vnode) {
|
||||
if (vnode.component) count++
|
||||
if (vnode.children) {
|
||||
vnode.children.forEach(child => {
|
||||
if (child.component || child.children) countComponents(child)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Use Vue DevTools instead for accurate counts
|
||||
console.log('Check Vue DevTools Components tab for instance counts')
|
||||
})
|
||||
```
|
||||
|
||||
## Alternatives to Wrapper Components
|
||||
|
||||
```vue
|
||||
<!-- Instead of a <Button> component for styling: -->
|
||||
<button class="btn btn-primary">Click</button>
|
||||
|
||||
<!-- Instead of a <Text> component: -->
|
||||
<span class="text-body">{{ content }}</span>
|
||||
|
||||
<!-- Instead of layout wrapper components in lists: -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- content -->
|
||||
</div>
|
||||
|
||||
<!-- Use CSS classes or Tailwind instead of component abstractions for styling -->
|
||||
```
|
||||
|
||||
## Impact Calculation
|
||||
|
||||
| List Size | Components per Item | Total Instances | Memory Impact |
|
||||
|-----------|---------------------|-----------------|---------------|
|
||||
| 100 items | 1 (flat) | 100 | Baseline |
|
||||
| 100 items | 3 (nested) | 300 | ~3x memory |
|
||||
| 100 items | 5 (deeply nested) | 500 | ~5x memory |
|
||||
| 1000 items | 1 (flat) | 1000 | High |
|
||||
| 1000 items | 5 (deeply nested) | 5000 | Very High |
|
||||
@@ -1,182 +0,0 @@
|
||||
---
|
||||
title: Use v-once and v-memo to Skip Unnecessary Updates
|
||||
impact: MEDIUM
|
||||
impactDescription: v-once skips all future updates for static content; v-memo conditionally memoizes subtrees
|
||||
type: efficiency
|
||||
tags: [vue3, performance, v-once, v-memo, optimization, directives]
|
||||
---
|
||||
|
||||
# Use v-once and v-memo to Skip Unnecessary Updates
|
||||
|
||||
**Impact: MEDIUM** - Vue re-evaluates templates on every reactive change. For content that never changes or changes infrequently, `v-once` and `v-memo` tell Vue to skip updates, reducing render work.
|
||||
|
||||
Use `v-once` for truly static content and `v-memo` for conditionally-static content in lists.
|
||||
|
||||
## Task List
|
||||
|
||||
- Apply `v-once` to elements that use runtime data but never need updating
|
||||
- Apply `v-memo` to list items that should only update on specific condition changes
|
||||
- Verify memoized content doesn't need to respond to other state changes
|
||||
- Profile with Vue DevTools to confirm update skipping
|
||||
|
||||
## v-once: Render Once, Never Update
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: Re-evaluated on every parent re-render -->
|
||||
<div class="terms-content">
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Version: {{ termsVersion }}</p>
|
||||
<div v-html="termsContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- This content NEVER changes, but Vue checks it every render -->
|
||||
<footer>
|
||||
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
|
||||
</footer>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Rendered once, skipped on all future updates -->
|
||||
<div class="terms-content" v-once>
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Version: {{ termsVersion }}</p>
|
||||
<div v-html="termsContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- v-once tells Vue this never needs to update -->
|
||||
<footer v-once>
|
||||
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// These values are set once at component creation
|
||||
const termsVersion = '2.1'
|
||||
const termsContent = fetchedTermsHTML
|
||||
const copyrightYear = 2024
|
||||
const companyName = 'Acme Corp'
|
||||
</script>
|
||||
```
|
||||
|
||||
## v-memo: Conditional Memoization for Lists
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: All items re-render when selectedId changes -->
|
||||
<div v-for="item in list" :key="item.id">
|
||||
<div :class="{ selected: item.id === selectedId }">
|
||||
<ExpensiveComponent :data="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Items only re-render when their selection state changes -->
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
v-memo="[item.id === selectedId]"
|
||||
>
|
||||
<div :class="{ selected: item.id === selectedId }">
|
||||
<ExpensiveComponent :data="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const list = ref([/* many items */])
|
||||
const selectedId = ref(null)
|
||||
|
||||
// When selectedId changes:
|
||||
// - Only the previously-selected item re-renders (selected: true -> false)
|
||||
// - Only the newly-selected item re-renders (selected: false -> true)
|
||||
// - All other items are SKIPPED (v-memo values unchanged)
|
||||
</script>
|
||||
```
|
||||
|
||||
## v-memo with Multiple Dependencies
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Re-render only when item's selection OR editing state changes -->
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
v-memo="[item.id === selectedId, item.id === editingId]"
|
||||
>
|
||||
<ItemCard
|
||||
:item="item"
|
||||
:selected="item.id === selectedId"
|
||||
:editing="item.id === editingId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const selectedId = ref(null)
|
||||
const editingId = ref(null)
|
||||
const items = ref([/* ... */])
|
||||
</script>
|
||||
```
|
||||
|
||||
## v-memo with Empty Array = v-once
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- v-memo="[]" is equivalent to v-once -->
|
||||
<div v-for="item in staticList" :key="item.id" v-memo="[]">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When NOT to Use These Directives
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- DON'T: Content that DOES need to update -->
|
||||
<div v-once>
|
||||
<span>Count: {{ count }}</span> <!-- count won't update! -->
|
||||
</div>
|
||||
|
||||
<!-- DON'T: When child components have their own reactive state -->
|
||||
<div v-memo="[selected]">
|
||||
<InputField v-model="item.name" /> <!-- v-model won't work properly -->
|
||||
</div>
|
||||
|
||||
<!-- DON'T: When the memoization benefit is minimal -->
|
||||
<span v-once>{{ simpleText }}</span> <!-- Overhead not worth it -->
|
||||
</template>
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Scenario | Without Directive | With v-once/v-memo |
|
||||
|----------|-------------------|-------------------|
|
||||
| Static header, parent re-renders 100x | Re-evaluated 100x | Evaluated 1x |
|
||||
| 1000 items, selection changes | 1000 items re-render | 2 items re-render |
|
||||
| Complex child component | Full re-render | Skipped if memoized |
|
||||
|
||||
## Debugging Memoized Components
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { onUpdated } from 'vue'
|
||||
|
||||
// This won't fire if v-memo prevents update
|
||||
onUpdated(() => {
|
||||
console.log('Component updated')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
@@ -1,187 +0,0 @@
|
||||
---
|
||||
title: Virtualize Large Lists to Avoid DOM Overload
|
||||
impact: HIGH
|
||||
impactDescription: Rendering thousands of list items creates excessive DOM nodes, causing slow renders and high memory usage
|
||||
type: efficiency
|
||||
tags: [vue3, performance, virtual-list, large-data, dom, optimization]
|
||||
---
|
||||
|
||||
# Virtualize Large Lists to Avoid DOM Overload
|
||||
|
||||
**Impact: HIGH** - Rendering all items in a large list (hundreds or thousands) creates massive amounts of DOM nodes. Each node consumes memory, slows down initial render, and makes updates expensive. List virtualization only renders visible items, dramatically improving performance.
|
||||
|
||||
Use a virtualization library when dealing with lists that could exceed 50-100 items, especially if items have complex content.
|
||||
|
||||
## Task List
|
||||
|
||||
- Identify lists that render more than 50-100 items
|
||||
- Install a virtualization library (vue-virtual-scroller, @tanstack/vue-virtual)
|
||||
- Replace standard `v-for` with virtualized component
|
||||
- Ensure list items have consistent or estimable heights
|
||||
- Test with realistic data volumes during development
|
||||
|
||||
## Recommended Libraries
|
||||
|
||||
| Library | Best For | Notes |
|
||||
|---------|----------|-------|
|
||||
| `vue-virtual-scroller` | General use, easy setup | Most popular, good defaults |
|
||||
| `@tanstack/vue-virtual` | Complex layouts, headless | Framework-agnostic, flexible |
|
||||
| `vue-virtual-scroll-grid` | Grid layouts | 2D virtualization |
|
||||
| `vueuc/VVirtualList` | Naive UI projects | Part of Naive UI ecosystem |
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: Renders ALL 10,000 items immediately -->
|
||||
<div class="user-list">
|
||||
<UserCard
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
const users = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
// 10,000 DOM nodes created, browser struggles
|
||||
users.value = await fetchAllUsers()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Only renders ~20 visible items at a time -->
|
||||
<RecycleScroller
|
||||
class="user-list"
|
||||
:items="users"
|
||||
:item-size="80"
|
||||
key-field="id"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<UserCard :user="item" />
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
const users = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
// 10,000 items in memory, but only ~20 DOM nodes
|
||||
users.value = await fetchAllUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-list {
|
||||
height: 600px; /* Container must have fixed height */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Using @tanstack/vue-virtual
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div ref="parentRef" class="list-container">
|
||||
<div
|
||||
:style="{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: 'relative'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="virtualRow in rowVirtualizer.getVirtualItems()"
|
||||
:key="virtualRow.key"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}"
|
||||
>
|
||||
<UserCard :user="users[virtualRow.index]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
|
||||
const users = ref([/* 10,000 users */])
|
||||
const parentRef = ref(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: users.value.length,
|
||||
getScrollElement: () => parentRef.value,
|
||||
estimateSize: () => 80, // Estimated row height
|
||||
overscan: 5 // Render 5 extra items above/below viewport
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-container {
|
||||
height: 600px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Dynamic Heights with vue-virtual-scroller
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- For variable height items, use DynamicScroller -->
|
||||
<DynamicScroller
|
||||
:items="messages"
|
||||
:min-item-size="54"
|
||||
key-field="id"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
>
|
||||
<ChatMessage :message="item" />
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Approach | 100 Items | 1,000 Items | 10,000 Items |
|
||||
|----------|-----------|-------------|--------------|
|
||||
| Regular v-for | ~100 DOM nodes | ~1,000 DOM nodes | ~10,000 DOM nodes |
|
||||
| Virtualized | ~20 DOM nodes | ~20 DOM nodes | ~20 DOM nodes |
|
||||
| Initial render | Fast | Slow | Very slow / crashes |
|
||||
| Virtualized render | Fast | Fast | Fast |
|
||||
|
||||
## When NOT to Virtualize
|
||||
|
||||
- Lists under 50 items with simple content
|
||||
- Lists where all items must be accessible to screen readers simultaneously
|
||||
- Print layouts where all content must render
|
||||
- SEO-critical content that must be in initial HTML
|
||||
@@ -1,166 +0,0 @@
|
||||
---
|
||||
title: Vue Plugin Best Practices
|
||||
impact: MEDIUM
|
||||
impactDescription: Incorrect plugin structure or injection key strategy causes install failures, collisions, and unsafe APIs
|
||||
type: best-practice
|
||||
tags: [vue3, plugins, provide-inject, typescript, dependency-injection]
|
||||
---
|
||||
|
||||
# Vue Plugin Best Practices
|
||||
|
||||
**Impact: MEDIUM** - Vue plugins should follow the `app.use()` contract, expose explicit capabilities, and use collision-safe injection keys. This keeps plugin setup predictable and composable across large apps.
|
||||
|
||||
## Task List
|
||||
|
||||
- Export plugins as an object with `install()` or as an install function
|
||||
- Use the `app` instance in `install()` to register components/directives/provides
|
||||
- Type plugin APIs with `Plugin` (and options tuple types when needed)
|
||||
- Use symbol keys (prefer `InjectionKey<T>`) for `provide/inject` in plugins
|
||||
- Add a small typed composable wrapper for required injections to fail fast
|
||||
|
||||
## Structure Plugins for `app.use()`
|
||||
|
||||
A Vue plugin must be either:
|
||||
- An object with `install(app, options?)`
|
||||
- A function with the same signature
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
const notAPlugin = {
|
||||
doSomething() {}
|
||||
}
|
||||
|
||||
app.use(notAPlugin)
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
import type { App } from 'vue'
|
||||
|
||||
interface PluginOptions {
|
||||
prefix?: string
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
const myPlugin = {
|
||||
install(app: App, options: PluginOptions = {}) {
|
||||
const { prefix = 'my', debug = false } = options
|
||||
|
||||
if (debug) {
|
||||
console.log('Installing myPlugin with prefix:', prefix)
|
||||
}
|
||||
|
||||
app.provide('myPlugin', { prefix })
|
||||
}
|
||||
}
|
||||
|
||||
app.use(myPlugin, { prefix: 'custom', debug: true })
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
import type { App } from 'vue'
|
||||
|
||||
function simplePlugin(app: App, options?: { message: string }) {
|
||||
app.config.globalProperties.$greet = () => options?.message ?? 'Hello!'
|
||||
}
|
||||
|
||||
app.use(simplePlugin, { message: 'Welcome!' })
|
||||
```
|
||||
|
||||
## Register Capabilities Explicitly in `install()`
|
||||
|
||||
Inside `install()`, wire behavior through Vue application APIs:
|
||||
- `app.component()` for global components
|
||||
- `app.directive()` for global directives
|
||||
- `app.provide()` for injectable services and config
|
||||
- `app.config.globalProperties` for optional global helpers (sparingly)
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
const uselessPlugin = {
|
||||
install(app, options) {
|
||||
const service = createService(options)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
const usefulPlugin = {
|
||||
install(app, options) {
|
||||
const service = createService(options)
|
||||
app.provide(serviceKey, service)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Type Plugin Contracts
|
||||
|
||||
Use Vue's `Plugin` type to keep install signatures and options type-safe.
|
||||
|
||||
```ts
|
||||
import type { App, Plugin } from 'vue'
|
||||
|
||||
interface MyOptions {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const myPlugin: Plugin<[MyOptions]> = {
|
||||
install(app: App, options: MyOptions) {
|
||||
app.provide(apiKeyKey, options.apiKey)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Use Symbol Injection Keys in Plugins
|
||||
|
||||
String keys can collide (`'http'`, `'config'`, `'i18n'`). Use symbol keys with `InjectionKey<T>` so injections are unique and typed.
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
export default {
|
||||
install(app) {
|
||||
app.provide('http', axios)
|
||||
app.provide('config', appConfig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
import type { InjectionKey } from 'vue'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
|
||||
interface AppConfig {
|
||||
apiUrl: string
|
||||
timeout: number
|
||||
}
|
||||
|
||||
export const httpKey: InjectionKey<AxiosInstance> = Symbol('http')
|
||||
export const configKey: InjectionKey<AppConfig> = Symbol('appConfig')
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
app.provide(httpKey, axios)
|
||||
app.provide(configKey, { apiUrl: '/api', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Provide Required Injection Helpers
|
||||
|
||||
Wrap required injections in composables that throw clear setup errors.
|
||||
|
||||
```ts
|
||||
import { inject } from 'vue'
|
||||
import { authKey, type AuthService } from '@/injection-keys'
|
||||
|
||||
export function useAuth(): AuthService {
|
||||
const auth = inject(authKey)
|
||||
if (!auth) {
|
||||
throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?')
|
||||
}
|
||||
return auth
|
||||
}
|
||||
```
|
||||
@@ -1,344 +0,0 @@
|
||||
---
|
||||
title: Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch)
|
||||
impact: MEDIUM
|
||||
impactDescription: Clear reactivity choices keep state predictable and reduce unnecessary updates in Vue 3 apps
|
||||
type: efficiency
|
||||
tags: [vue3, reactivity, ref, reactive, shallowRef, computed, watch, watchEffect, external-state, best-practice]
|
||||
---
|
||||
|
||||
# Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch)
|
||||
|
||||
**Impact: MEDIUM** - Choose the right reactive primitive first, derive with `computed`, and use watchers only for side effects.
|
||||
|
||||
This reference covers the core reactivity decisions for local state, external data, derived values, and effects.
|
||||
|
||||
## Task List
|
||||
|
||||
- Declare reactive state correctly
|
||||
- Always use `shallowRef()` instead of `ref()` for primitive values
|
||||
- Choose the correct reactive declaration method for objects/arrays/map/set
|
||||
- Follow best practices for `reactive`
|
||||
- Avoid destructuring from `reactive()` directly
|
||||
- Watch correctly for `reactive`
|
||||
- Follow best practices for `computed`
|
||||
- Prefer `computed` over watcher-assigned derived refs
|
||||
- Keep filtered/sorted derivations out of templates
|
||||
- Use `computed` for reusable class/style logic
|
||||
- Keep computed getters pure (no side effects) and put side effects in watchers
|
||||
- Follow best practices for watchers
|
||||
- Use `immediate: true` instead of duplicate initial calls
|
||||
- Clean up async effects for watchers
|
||||
|
||||
## Declare reactive state correctly
|
||||
|
||||
### Always use `shallowRef()` instead of `ref()` for primitive values (string, number, boolean, null, etc.) for better performance.
|
||||
|
||||
**Incorrect:**
|
||||
```ts
|
||||
import { ref } from 'vue'
|
||||
const count = ref(0)
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```ts
|
||||
import { shallowRef } from 'vue'
|
||||
const count = shallowRef(0)
|
||||
```
|
||||
|
||||
### Choose the correct reactive declaration method for objects/arrays/map/set
|
||||
|
||||
Use `ref()` when you often **replace the entire value** (`state.value = newObj`) and still want deep reactivity inside it, usually used for:
|
||||
|
||||
- Frequently reassigned state (replace fetched object/list, reset to defaults, switch presets).
|
||||
- Composable return values where updates happen mostly via `.value` reassignment.
|
||||
|
||||
Use `reactive()` when you mainly **mutate properties** and full replacement is uncommon, usually used for:
|
||||
|
||||
- “Single state object” patterns (stores/forms): `state.count++`, `state.items.push(...)`, `state.user.name = ...`.
|
||||
- Situations where you want to avoid `.value` and update nested fields in place.
|
||||
|
||||
```ts
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
count: 0,
|
||||
user: { name: 'Alice', age: 30 }
|
||||
})
|
||||
|
||||
state.count++ // ✅ reactive
|
||||
state.user.age = 31 // ✅ reactive
|
||||
// ❌ avoid replacing the reactive object reference:
|
||||
// state = reactive({ count: 1 })
|
||||
```
|
||||
|
||||
Use `shallowRef()` when the value is **opaque / should not be proxied** (class instances, external library objects, very large nested data) and you only want updates to trigger when you **replace** `state.value` (no deep tracking), usually used for:
|
||||
|
||||
- Storing external instances/handles (SDK clients, class instances) without Vue proxying internals.
|
||||
- Large data where you update by replacing the root reference (immutable-style updates).
|
||||
|
||||
```ts
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
const user = shallowRef({ name: 'Alice', age: 30 })
|
||||
|
||||
user.value.age = 31 // ❌ not reactive
|
||||
user.value = { name: 'Bob', age: 25 } // ✅ triggers update
|
||||
```
|
||||
|
||||
Use `shallowReactive()` when you want **only top-level properties** reactive; nested objects remain raw, usually used for:
|
||||
|
||||
- Container objects where only top-level keys change and nested payloads should stay unmanaged/unproxied.
|
||||
- Mixed structures where Vue tracks the wrapper object, but not deeply nested or foreign objects.
|
||||
|
||||
```ts
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
const state = shallowReactive({
|
||||
count: 0,
|
||||
user: { name: 'Alice', age: 30 }
|
||||
})
|
||||
|
||||
state.count++ // ✅ reactive
|
||||
state.user.age = 31 // ❌ not reactive
|
||||
```
|
||||
|
||||
## Best practices for `reactive`
|
||||
|
||||
### Avoid destructuring from `reactive()` directly
|
||||
|
||||
**BAD:**
|
||||
|
||||
```ts
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({ count: 0 })
|
||||
const { count } = state // ❌ disconnected from reactivity
|
||||
```
|
||||
|
||||
### Watch correctly for reactive
|
||||
|
||||
**BAD:**
|
||||
|
||||
passing a non-getter value into `watch()`
|
||||
|
||||
```ts
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const state = reactive({ count: 0 })
|
||||
|
||||
// ❌ watch expects a getter, ref, reactive object, or array of these
|
||||
watch(state.count, () => { /* ... */ })
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
|
||||
preserve reactivity with `toRefs()` and use a getter for `watch()`
|
||||
|
||||
```ts
|
||||
import { reactive, toRefs, watch } from 'vue'
|
||||
|
||||
const state = reactive({ count: 0 })
|
||||
const { count } = toRefs(state) // ✅ count is a ref
|
||||
|
||||
watch(count, () => { /* ... */ }) // ✅
|
||||
watch(() => state.count, () => { /* ... */ }) // ✅
|
||||
```
|
||||
|
||||
## Best practices for `computed`
|
||||
|
||||
### Prefer `computed` over watcher-assigned derived refs
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
const items = ref([{ price: 10 }, { price: 20 }])
|
||||
const total = ref(0)
|
||||
|
||||
watchEffect(() => {
|
||||
total.value = items.value.reduce((sum, item) => sum + item.price, 0)
|
||||
})
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([{ price: 10 }, { price: 20 }])
|
||||
const total = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.price, 0)
|
||||
)
|
||||
```
|
||||
|
||||
### Keep filtered/sorted derivations out of templates
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<li v-for="item in items.filter(item => item.active)" :key="item.id">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
|
||||
<li v-for="item in getSortedItems()" :key="item.id">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const items = ref([
|
||||
{ id: 1, name: 'B', active: true },
|
||||
{ id: 2, name: 'A', active: false }
|
||||
])
|
||||
|
||||
function getSortedItems() {
|
||||
return [...items.value].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([
|
||||
{ id: 1, name: 'B', active: true },
|
||||
{ id: 2, name: 'A', active: false }
|
||||
])
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
items.value
|
||||
.filter(item => item.active)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li v-for="item in visibleItems" :key="item.id">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Use `computed` for reusable class/style logic
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<button :class="{ btn: true, 'btn-primary': type === 'primary' && !disabled, 'btn-disabled': disabled }">
|
||||
{{ label }}
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: { type: String, default: 'primary' },
|
||||
disabled: Boolean,
|
||||
label: String
|
||||
})
|
||||
|
||||
const buttonClasses = computed(() => ({
|
||||
btn: true,
|
||||
[`btn-${props.type}`]: !props.disabled,
|
||||
'btn-disabled': props.disabled
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="buttonClasses">
|
||||
{{ label }}
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Keep computed getters pure (no side effects) and put side effects in watchers instead
|
||||
|
||||
A computed getter should only derive a value. No mutation, no API calls, no storage writes, no event emits.
|
||||
([Reference](https://vuejs.org/guide/essentials/computed.html#best-practices))
|
||||
|
||||
**BAD:**
|
||||
|
||||
side effects inside computed
|
||||
|
||||
```ts
|
||||
const count = ref(0)
|
||||
|
||||
const doubled = computed(() => {
|
||||
// ❌ side effect
|
||||
if (count.value > 10) console.warn('Too big!')
|
||||
return count.value * 2
|
||||
})
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
|
||||
pure computed + `watch()` for side effects
|
||||
|
||||
```ts
|
||||
const count = ref(0)
|
||||
const doubled = computed(() => count.value * 2)
|
||||
|
||||
watch(count, (value) => {
|
||||
if (value > 10) console.warn('Too big!')
|
||||
})
|
||||
```
|
||||
|
||||
## Best practices for watchers
|
||||
|
||||
### Use `immediate: true` instead of duplicate initial calls
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const userId = ref(1)
|
||||
|
||||
function loadUser(id) {
|
||||
// ...
|
||||
}
|
||||
|
||||
onMounted(() => loadUser(userId.value))
|
||||
watch(userId, (id) => loadUser(id))
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const userId = ref(1)
|
||||
|
||||
watch(
|
||||
userId,
|
||||
(id) => loadUser(id),
|
||||
{ immediate: true }
|
||||
)
|
||||
```
|
||||
|
||||
### Clean up async effects for watchers
|
||||
|
||||
When reacting to rapid changes (search boxes, filters), cancel the previous request.
|
||||
|
||||
**GOOD:**
|
||||
|
||||
```ts
|
||||
const query = ref('')
|
||||
const results = ref<string[]>([])
|
||||
|
||||
watch(query, async (q, _prev, onCleanup) => {
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
results.value = await res.json()
|
||||
})
|
||||
```
|
||||
@@ -1,201 +0,0 @@
|
||||
---
|
||||
title: Render Function Patterns and Performance
|
||||
impact: MEDIUM
|
||||
impactDescription: Render functions require explicit patterns for lists, events, v-model, and performance to stay correct and maintainable
|
||||
type: best-practice
|
||||
tags: [vue3, render-function, h, v-model, directives, performance, jsx]
|
||||
---
|
||||
|
||||
# Render Function Patterns and Performance
|
||||
|
||||
**Impact: MEDIUM** - Render functions are powerful but opt out of template compiler optimizations. Use them intentionally and apply the key patterns below to keep output correct and performant.
|
||||
|
||||
## Task List
|
||||
|
||||
- Prefer templates; use render functions only when templates cannot express the logic
|
||||
- Always add stable keys when rendering lists with `h()`/JSX
|
||||
- Use `withModifiers` / `withKeys` for event modifiers
|
||||
- Implement `v-model` via `modelValue` + `onUpdate:modelValue`
|
||||
- Apply custom directives with `withDirectives`
|
||||
- Use functional components for stateless presentational UI
|
||||
|
||||
## Prefer templates over render functions
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
const render = () => h('div', `Count: ${count.value}`)
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Count: {{ count }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Always add keys for list rendering
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const items = ref([{ id: 1, name: 'Apple' }])
|
||||
|
||||
return () => h('ul',
|
||||
items.value.map(item => h('li', item.name))
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const items = ref([{ id: 1, name: 'Apple' }])
|
||||
|
||||
return () => h('ul',
|
||||
items.value.map(item => h('li', { key: item.id }, item.name))
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Use `withModifiers` / `withKeys` for event modifiers
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
return () => h('button', { onClick: handleClick }, 'Click')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import { h, withModifiers, withKeys } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handleClick = () => {}
|
||||
const handleEnter = () => {}
|
||||
|
||||
return () => h('div', [
|
||||
h('button', {
|
||||
onClick: withModifiers(handleClick, ['stop', 'prevent'])
|
||||
}, 'Click'),
|
||||
h('input', {
|
||||
onKeyup: withKeys(handleEnter, ['enter'])
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implement `v-model` explicitly
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
return () => h(CustomInput, { modelValue: text.value })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
return () => h(CustomInput, {
|
||||
modelValue: text.value,
|
||||
'onUpdate:modelValue': (value) => { text.value = value }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Use `withDirectives` for custom directives
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
const vFocus = { mounted: (el) => el.focus() }
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => h('input', { 'v-focus': true })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import { h, withDirectives } from 'vue'
|
||||
|
||||
const vFocus = { mounted: (el) => el.focus() }
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => withDirectives(h('input'), [[vFocus]])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prefer functional components for stateless UI
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => h('span', { class: 'badge' }, 'New')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
function Badge(props, { slots }) {
|
||||
return h('span', { class: 'badge' }, slots.default?.())
|
||||
}
|
||||
|
||||
Badge.props = ['variant']
|
||||
|
||||
export default Badge
|
||||
```
|
||||
@@ -1,310 +0,0 @@
|
||||
---
|
||||
title: Single-File Component Structure, Styling, and Template Patterns
|
||||
impact: MEDIUM
|
||||
impactDescription: Consistent SFC structure and styling choices improve maintainability, tooling support, and render performance
|
||||
type: best-practice
|
||||
tags: [vue3, sfc, scoped-css, styles, build-tools, performance, template, v-html, v-for, computed, v-if, v-show]
|
||||
---
|
||||
|
||||
# Single-File Component Structure, Styling, and Template Patterns
|
||||
|
||||
**Impact: MEDIUM** - Using SFCs with consistent structure and performant styling keeps components easier to maintain and avoids unnecessary render overhead.
|
||||
|
||||
## Task List
|
||||
|
||||
- Use `.vue` SFCs instead of separate `.js`/`.ts` and `.css` files for components
|
||||
- Colocate template, script, and styles in the same SFC by default
|
||||
- Use PascalCase for component names in templates and filenames
|
||||
- Prefer component-scoped styles
|
||||
- Prefer class selectors (not element selectors) in scoped CSS for performance
|
||||
- Access DOM / component refs with `useTemplateRef()` in Vue 3.5+
|
||||
- Use camelCase keys in `:style` bindings for consistency and IDE support
|
||||
- Use `v-for` and `v-if` correctly
|
||||
- Never use `v-html` with untrusted/user-provided content
|
||||
- Choose `v-if` vs `v-show` based on toggle frequency and initial render cost
|
||||
|
||||
## Colocate template, script, and styles
|
||||
|
||||
**BAD:**
|
||||
```
|
||||
components/
|
||||
├── UserCard.vue
|
||||
├── UserCard.js
|
||||
└── UserCard.css
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<!-- components/UserCard.vue -->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const displayName = computed(() =>
|
||||
`${props.user.firstName} ${props.user.lastName}`
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<h3 class="name">{{ displayName }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Use PascalCase for component names
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import userProfile from './user-profile.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<user-profile :user="currentUser" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import UserProfile from './UserProfile.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserProfile :user="currentUser" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Best practices for `<style>` block in SFCs
|
||||
|
||||
### Prefer component-scoped styles
|
||||
|
||||
- Use `<style scoped>` for styles that belong to a component.
|
||||
- Keep **global CSS** in a dedicated file (e.g. `src/assets/main.css`) for resets, typography, tokens, etc.
|
||||
- Use `:deep()` sparingly (edge cases only).
|
||||
|
||||
**BAD:**
|
||||
|
||||
```vue
|
||||
<style>
|
||||
/* ❌ leaks everywhere */
|
||||
button { border-radius: 999px; }
|
||||
</style>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
.button { border-radius: 999px; }
|
||||
</style>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
|
||||
```css
|
||||
/* src/assets/main.css */
|
||||
/* ✅ resets, tokens, typography, app-wide rules */
|
||||
:root { --radius: 999px; }
|
||||
```
|
||||
|
||||
### Use class selectors in scoped CSS
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ subtitle }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
article { max-width: 800px; }
|
||||
h1 { font-size: 2rem; }
|
||||
p { line-height: 1.6; }
|
||||
</style>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<article class="article">
|
||||
<h1 class="article-title">{{ title }}</h1>
|
||||
<p class="article-subtitle">{{ subtitle }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article { max-width: 800px; }
|
||||
.article-title { font-size: 2rem; }
|
||||
.article-subtitle { line-height: 1.6; }
|
||||
</style>
|
||||
```
|
||||
|
||||
## Access DOM / component refs with `useTemplateRef()`
|
||||
|
||||
For Vue 3.5+: use `useTemplateRef()` to access template refs.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { onMounted, useTemplateRef } from 'vue'
|
||||
|
||||
const inputRef = useTemplateRef<HTMLInputElement>('input')
|
||||
|
||||
onMounted(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input ref="input" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Use camelCase in `:style` bindings
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<div :style="{ 'font-size': fontSize + 'px', 'background-color': bg }">
|
||||
Content
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<div :style="{ fontSize: fontSize + 'px', backgroundColor: bg }">
|
||||
Content
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Use `v-for` and `v-if` correctly
|
||||
|
||||
### Always provide a stable `:key`
|
||||
|
||||
- Prefer primitive keys (`string | number`).
|
||||
- Avoid using objects as keys.
|
||||
|
||||
**GOOD:**
|
||||
|
||||
```vue
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<input v-model="item.text" />
|
||||
</li>
|
||||
```
|
||||
|
||||
### Avoid `v-if` and `v-for` on the same element
|
||||
|
||||
It leads to unclear intent and unnecessary work.
|
||||
([Reference](https://vuejs.org/guide/essentials/list.html#v-for-with-v-if))
|
||||
|
||||
**To filter items**
|
||||
**BAD:**
|
||||
|
||||
```vue
|
||||
<li v-for="user in users" v-if="user.active" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const activeUsers = computed(() => users.value.filter(u => u.active))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li v-for="user in activeUsers" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
```
|
||||
|
||||
**To conditionally show/hide the entire list**
|
||||
**GOOD:**
|
||||
|
||||
```vue
|
||||
<ul v-if="shouldShowUsers">
|
||||
<li v-for="user in users" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Never render untrusted HTML with `v-html`
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- DANGEROUS: untrusted input can inject scripts -->
|
||||
<article v-html="userProvidedContent"></article>
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const props = defineProps<{
|
||||
trustedHtml?: string
|
||||
plainText: string
|
||||
}>()
|
||||
|
||||
const safeHtml = computed(() => DOMPurify.sanitize(props.trustedHtml ?? ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Preferred: escaped interpolation -->
|
||||
<p>{{ props.plainText }}</p>
|
||||
|
||||
<!-- Only for trusted/sanitized HTML -->
|
||||
<article v-html="safeHtml"></article>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Choose `v-if` vs `v-show` by toggle behavior
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- Frequent toggles with v-if cause repeated mount/unmount -->
|
||||
<ComplexPanel v-if="isPanelOpen" />
|
||||
|
||||
<!-- Rarely shown content with v-show pays initial render cost -->
|
||||
<AdminPanel v-show="isAdmin" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- Frequent toggles: keep in DOM, toggle display -->
|
||||
<ComplexPanel v-show="isPanelOpen" />
|
||||
|
||||
<!-- Rare condition: lazy render only when true -->
|
||||
<AdminPanel v-if="isAdmin" />
|
||||
</template>
|
||||
```
|
||||
@@ -1,135 +0,0 @@
|
||||
---
|
||||
title: State Management Strategy
|
||||
impact: HIGH
|
||||
impactDescription: Choosing the wrong store pattern can cause SSR request leaks, brittle mutation flows, and poor scaling
|
||||
type: best-practice
|
||||
tags: [vue3, state-management, pinia, composables, ssr, vueuse]
|
||||
---
|
||||
|
||||
# State Management Strategy
|
||||
|
||||
**Impact: HIGH** - Use the lightest state solution that fits your app architecture. SPA-only apps can use lightweight global composables, while SSR/Nuxt apps should default to Pinia for request-safe isolation and predictable tooling.
|
||||
|
||||
## Task List
|
||||
|
||||
- Keep state local first, then promote to shared/global only when needed
|
||||
- Use singleton composables only in non-SSR applications
|
||||
- Expose global state as readonly and mutate through explicit actions
|
||||
- Prefer Pinia for SSR/Nuxt, large apps, and advanced debugging/plugin needs
|
||||
- Avoid exporting mutable module-level reactive state directly
|
||||
|
||||
## Choose the Lightest Store Approach
|
||||
|
||||
- **Feature composable:** Default for reusable logic with local/feature-level state.
|
||||
- **Singleton composable or VueUse `createGlobalState`:** Small non-SSR apps needing shared app state.
|
||||
- **Pinia:** SSR/Nuxt apps, medium-to-large apps, and cases requiring DevTools, plugins, or action tracing.
|
||||
|
||||
## Avoid Exporting Mutable Module State
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
// store/cart.ts
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const cart = reactive({
|
||||
items: [] as Array<{ id: string; qty: number }>
|
||||
})
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```ts
|
||||
// composables/useCartStore.ts
|
||||
import { reactive, readonly } from 'vue'
|
||||
|
||||
let _store: ReturnType<typeof createCartStore> | null = null
|
||||
|
||||
function createCartStore() {
|
||||
const state = reactive({
|
||||
items: [] as Array<{ id: string; qty: number }>
|
||||
})
|
||||
|
||||
function addItem(id: string, qty = 1) {
|
||||
const existing = state.items.find((item) => item.id === id)
|
||||
if (existing) {
|
||||
existing.qty += qty
|
||||
return
|
||||
}
|
||||
state.items.push({ id, qty })
|
||||
}
|
||||
|
||||
return {
|
||||
state: readonly(state),
|
||||
addItem
|
||||
}
|
||||
}
|
||||
|
||||
export function useCartStore() {
|
||||
if (!_store) _store = createCartStore()
|
||||
return _store
|
||||
}
|
||||
```
|
||||
|
||||
## Do Not Use Runtime Singletons in SSR
|
||||
|
||||
Module singletons live for the runtime lifetime. In SSR this can leak state between requests.
|
||||
|
||||
**BAD:**
|
||||
```ts
|
||||
// shared singleton reused across requests
|
||||
const cartStore = useCartStore()
|
||||
|
||||
export function useServerCart() {
|
||||
return cartStore
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
|
||||
> `pinia` dependency required.
|
||||
|
||||
```ts
|
||||
// stores/cart.ts
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCartStore = defineStore('cart', {
|
||||
state: () => ({
|
||||
items: [] as Array<{ id: string; qty: number }>
|
||||
}),
|
||||
actions: {
|
||||
addItem(id: string, qty = 1) {
|
||||
const existing = this.items.find((item) => item.id === id)
|
||||
if (existing) {
|
||||
existing.qty += qty
|
||||
return
|
||||
}
|
||||
this.items.push({ id, qty })
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Use `createGlobalState` for Small SPA Global State
|
||||
|
||||
> `@vueuse/core` dependency required.
|
||||
|
||||
If the app is non-SSR and already uses VueUse, `createGlobalState` removes singleton boilerplate.
|
||||
|
||||
```ts
|
||||
import { createGlobalState } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useAuthState = createGlobalState(() => {
|
||||
const token = ref<string | null>(null)
|
||||
const isAuthenticated = computed(() => token.value !== null)
|
||||
|
||||
function setToken(next: string | null) {
|
||||
token.value = next
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
isAuthenticated,
|
||||
setToken
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -1,187 +0,0 @@
|
||||
---
|
||||
title: Avoid Expensive Operations in Updated Hook
|
||||
impact: MEDIUM
|
||||
impactDescription: Heavy computations in updated hook cause performance bottlenecks and potential infinite loops
|
||||
type: capability
|
||||
tags: [vue3, vue2, lifecycle, updated, performance, optimization, reactivity]
|
||||
---
|
||||
|
||||
# Avoid Expensive Operations in Updated Hook
|
||||
|
||||
**Impact: MEDIUM** - The `updated` hook runs after every reactive state change that causes a re-render. Placing expensive operations, API calls, or state mutations here can cause severe performance degradation, infinite loops, and dropped frames below the optimal 60fps threshold.
|
||||
|
||||
Use `updated`/`onUpdated` sparingly for post-DOM-update operations that cannot be handled by watchers or computed properties. For most reactive data handling, prefer watchers (`watch`/`watchEffect`) which provide more control over what triggers the callback.
|
||||
|
||||
## Task List
|
||||
|
||||
- Never perform API calls in updated hook
|
||||
- Never mutate reactive state inside updated (causes infinite loops)
|
||||
- Use conditional checks to verify updates are relevant before acting
|
||||
- Prefer `watch` or `watchEffect` for reacting to specific data changes
|
||||
- Use throttling/debouncing if updated operations are expensive
|
||||
- Reserve updated for low-level DOM synchronization tasks
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
// BAD: API call in updated - fires on every re-render
|
||||
export default {
|
||||
data() {
|
||||
return { items: [], lastUpdate: null }
|
||||
},
|
||||
updated() {
|
||||
// This runs after every single state change!
|
||||
fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.items)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// BAD: State mutation in updated - infinite loop
|
||||
export default {
|
||||
data() {
|
||||
return { renderCount: 0 }
|
||||
},
|
||||
updated() {
|
||||
// This causes another update, which triggers updated again!
|
||||
this.renderCount++ // Infinite loop
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// BAD: Heavy computation on every update
|
||||
export default {
|
||||
updated() {
|
||||
// Expensive operation runs on every keystroke, every state change
|
||||
this.processedData = this.heavyComputation(this.rawData)
|
||||
this.analytics = this.calculateMetrics(this.allData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import debounce from 'lodash-es/debounce'
|
||||
|
||||
// GOOD: Use watcher for specific data changes
|
||||
export default {
|
||||
data() {
|
||||
return { items: [] }
|
||||
},
|
||||
watch: {
|
||||
// Only fires when items actually changes
|
||||
items: {
|
||||
handler(newItems) {
|
||||
this.syncToServer(newItems)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
syncToServer: debounce(function(items) {
|
||||
fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(items)
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- GOOD: Composition API with targeted watchers -->
|
||||
<script setup>
|
||||
import { ref, watch, onUpdated } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
const items = ref([])
|
||||
const scrollContainer = ref(null)
|
||||
|
||||
// Watch specific data - not all updates
|
||||
watch(items, (newItems) => {
|
||||
syncToServer(newItems)
|
||||
}, { deep: true })
|
||||
|
||||
const syncToServer = useDebounceFn((items) => {
|
||||
fetch('/api/sync', { method: 'POST', body: JSON.stringify(items) })
|
||||
}, 500)
|
||||
|
||||
// Only use onUpdated for DOM synchronization
|
||||
onUpdated(() => {
|
||||
// Scroll to bottom only if content changed height
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GOOD: Conditional check in updated hook
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
content: '',
|
||||
lastSyncedContent: ''
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
// Only act if specific condition is met
|
||||
if (this.content !== this.lastSyncedContent) {
|
||||
this.syncContent()
|
||||
this.lastSyncedContent = this.content
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
syncContent: debounce(function() {
|
||||
// Sync logic
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Valid Use Cases for Updated Hook
|
||||
|
||||
```javascript
|
||||
// GOOD: Low-level DOM synchronization
|
||||
export default {
|
||||
updated() {
|
||||
// Sync third-party library with Vue's DOM
|
||||
this.thirdPartyWidget.refresh()
|
||||
|
||||
// Update scroll position after content change
|
||||
this.$nextTick(() => {
|
||||
this.maintainScrollPosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prefer Computed Properties for Derived Data
|
||||
|
||||
```javascript
|
||||
// BAD: Calculating derived data in updated
|
||||
export default {
|
||||
data() {
|
||||
return { numbers: [1, 2, 3, 4, 5] }
|
||||
},
|
||||
updated() {
|
||||
this.sum = this.numbers.reduce((a, b) => a + b, 0) // Causes another update!
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Use computed property instead
|
||||
export default {
|
||||
data() {
|
||||
return { numbers: [1, 2, 3, 4, 5] }
|
||||
},
|
||||
computed: {
|
||||
sum() {
|
||||
return this.numbers.reduce((a, b) => a + b, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 hyf0, SerKo <https://github.com/serkodev>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: vue-router-best-practices
|
||||
description: "Vue Router 4 patterns, navigation guards, route params, and route-component lifecycle interactions."
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
author: github.com/vuejs-ai
|
||||
---
|
||||
|
||||
Vue Router best practices, common gotchas, and navigation patterns.
|
||||
|
||||
### Navigation Guards
|
||||
- Navigating between same route with different params → See [router-beforeenter-no-param-trigger](reference/router-beforeenter-no-param-trigger.md)
|
||||
- Accessing component instance in beforeRouteEnter guard → See [router-beforerouteenter-no-this](reference/router-beforerouteenter-no-this.md)
|
||||
- Navigation guard making API calls without awaiting → See [router-guard-async-await-pattern](reference/router-guard-async-await-pattern.md)
|
||||
- Users trapped in infinite redirect loops → See [router-navigation-guard-infinite-loop](reference/router-navigation-guard-infinite-loop.md)
|
||||
- Navigation guard using deprecated next() function → See [router-navigation-guard-next-deprecated](reference/router-navigation-guard-next-deprecated.md)
|
||||
|
||||
### Route Lifecycle
|
||||
- Stale data when navigating between same route → See [router-param-change-no-lifecycle](reference/router-param-change-no-lifecycle.md)
|
||||
- Event listeners persisting after component unmounts → See [router-simple-routing-cleanup](reference/router-simple-routing-cleanup.md)
|
||||
|
||||
### Setup
|
||||
- Building production single-page application → See [router-use-vue-router-for-production](reference/router-use-vue-router-for-production.md)
|
||||
@@ -1,5 +0,0 @@
|
||||
# Sync Info
|
||||
|
||||
- **Source:** `vendor/vuejs-ai/skills/vue-router-best-practices`
|
||||
- **Git SHA:** `f3dd1bf4d3ac78331bdc903e4519d561c538ca6a`
|
||||
- **Synced:** 2026-03-16
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
---
|
||||
title: Per-Route beforeEnter Guards Ignore Param/Query Changes
|
||||
impact: MEDIUM
|
||||
impactDescription: Route-level beforeEnter guards don't fire when only params, query, or hash change, causing unexpected bypasses of validation logic
|
||||
type: gotcha
|
||||
tags: [vue3, vue-router, navigation-guards, params, query]
|
||||
---
|
||||
|
||||
# Per-Route beforeEnter Guards Ignore Param/Query Changes
|
||||
|
||||
**Impact: MEDIUM** - The `beforeEnter` guard defined in route configuration only triggers when entering a route from a DIFFERENT route. Changes to params, query strings, or hash within the same route do NOT trigger `beforeEnter`, potentially bypassing important validation logic.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use in-component `onBeforeRouteUpdate` for param/query changes
|
||||
- [ ] Or use global `beforeEach` with route.params/query checks
|
||||
- [ ] Document which guards protect which scenarios
|
||||
- [ ] Test navigation between same route with different params
|
||||
|
||||
## The Problem
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
const routes = [
|
||||
{
|
||||
path: '/orders/:id',
|
||||
component: OrderDetail,
|
||||
beforeEnter: async (to, from) => {
|
||||
// This runs when entering from /products
|
||||
// But NOT when navigating from /orders/1 to /orders/2!
|
||||
const order = await checkOrderAccess(to.params.id)
|
||||
if (!order.canView) {
|
||||
return '/unauthorized'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Scenario:**
|
||||
1. User navigates from `/products` to `/orders/1` - beforeEnter runs, access checked
|
||||
2. User navigates from `/orders/1` to `/orders/2` - beforeEnter DOES NOT run!
|
||||
3. User might access order they don't have permission for!
|
||||
|
||||
## What Triggers beforeEnter vs. What Doesn't
|
||||
|
||||
| Navigation | beforeEnter fires? |
|
||||
|------------|-------------------|
|
||||
| `/products` → `/orders/1` | YES |
|
||||
| `/orders/1` → `/orders/2` | NO |
|
||||
| `/orders/1` → `/orders/1?tab=details` | NO |
|
||||
| `/orders/1#section` → `/orders/1#other` | NO |
|
||||
| `/orders/1` → `/products` → `/orders/2` | YES (leaving and re-entering) |
|
||||
|
||||
## Solution 1: Add In-Component Guard
|
||||
|
||||
```vue
|
||||
<!-- OrderDetail.vue -->
|
||||
<script setup>
|
||||
import { onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
// Handle param changes within the same route
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
if (to.params.id !== from.params.id) {
|
||||
const order = await checkOrderAccess(to.params.id)
|
||||
if (!order.canView) {
|
||||
return '/unauthorized'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Solution 2: Use Global beforeEach Instead
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
router.beforeEach(async (to, from) => {
|
||||
// Handle all order access checks globally
|
||||
if (to.name === 'OrderDetail') {
|
||||
// This runs on EVERY navigation to this route, including param changes
|
||||
const order = await checkOrderAccess(to.params.id)
|
||||
if (!order.canView) {
|
||||
return '/unauthorized'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Solution 3: Combine Both Guards
|
||||
|
||||
```javascript
|
||||
// router.js - For entering from different route
|
||||
const routes = [
|
||||
{
|
||||
path: '/orders/:id',
|
||||
component: OrderDetail,
|
||||
beforeEnter: (to) => validateOrderAccess(to.params.id)
|
||||
}
|
||||
]
|
||||
|
||||
// In component - For param changes within route
|
||||
// OrderDetail.vue
|
||||
onBeforeRouteUpdate((to) => validateOrderAccess(to.params.id))
|
||||
|
||||
// Shared validation function
|
||||
async function validateOrderAccess(orderId) {
|
||||
const order = await checkOrderAccess(orderId)
|
||||
if (!order.canView) {
|
||||
return '/unauthorized'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Solution 4: Use beforeEnter with Array of Guards
|
||||
|
||||
```javascript
|
||||
// guards/orderGuards.js
|
||||
export const orderAccessGuard = async (to) => {
|
||||
const order = await checkOrderAccess(to.params.id)
|
||||
if (!order.canView) {
|
||||
return '/unauthorized'
|
||||
}
|
||||
}
|
||||
|
||||
// router.js
|
||||
const routes = [
|
||||
{
|
||||
path: '/orders/:id',
|
||||
component: OrderDetail,
|
||||
beforeEnter: [orderAccessGuard] // Can add multiple guards
|
||||
}
|
||||
]
|
||||
|
||||
// Still need in-component guard for param changes!
|
||||
```
|
||||
|
||||
## Full Navigation Guard Execution Order
|
||||
|
||||
Understanding when each guard type fires:
|
||||
|
||||
```
|
||||
1. beforeRouteLeave (in-component, leaving component)
|
||||
2. beforeEach (global)
|
||||
3. beforeEnter (per-route, ONLY when entering from different route)
|
||||
4. beforeRouteEnter (in-component, entering component)
|
||||
5. beforeResolve (global)
|
||||
6. afterEach (global, after navigation confirmed)
|
||||
|
||||
For param/query changes on same route:
|
||||
1. beforeRouteUpdate (in-component) - ONLY this fires!
|
||||
2. beforeEach (global)
|
||||
3. beforeResolve (global)
|
||||
4. afterEach (global)
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **beforeEnter is for route ENTRY only** - Not for within-route changes
|
||||
2. **Use onBeforeRouteUpdate for param changes** - This is the in-component solution
|
||||
3. **Global beforeEach always runs** - Good for centralized validation
|
||||
4. **Test param change scenarios** - Easy to miss during development
|
||||
5. **Consider security implications** - Param-based access control needs both guards
|
||||
|
||||
## Reference
|
||||
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
|
||||
- [Vue Router Per-Route Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html#per-route-guard)
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
---
|
||||
title: beforeRouteEnter Cannot Access Component Instance
|
||||
impact: MEDIUM
|
||||
impactDescription: The beforeRouteEnter guard runs before component creation, so 'this' is undefined; use the next callback to access the instance
|
||||
type: gotcha
|
||||
tags: [vue3, vue-router, navigation-guards, lifecycle, this]
|
||||
---
|
||||
|
||||
# beforeRouteEnter Cannot Access Component Instance
|
||||
|
||||
**Impact: MEDIUM** - The `beforeRouteEnter` in-component navigation guard executes BEFORE the component is created, meaning you cannot access `this` or any component instance properties. This is the ONLY navigation guard that supports a callback in the `next()` function to access the component instance after navigation.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use next(vm => ...) callback to access component instance
|
||||
- [ ] Or use composition API guards which have different patterns
|
||||
- [ ] Move data fetching logic appropriately based on timing needs
|
||||
- [ ] Consider using global guards for data that doesn't need component access
|
||||
|
||||
## The Problem
|
||||
|
||||
```javascript
|
||||
// Options API - WRONG: this is undefined
|
||||
export default {
|
||||
data() {
|
||||
return { user: null }
|
||||
},
|
||||
beforeRouteEnter(to, from, next) {
|
||||
// BUG: this is undefined here - component doesn't exist yet!
|
||||
this.user = await fetchUser(to.params.id) // ERROR!
|
||||
next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Solution: Use next() Callback (Options API)
|
||||
|
||||
```javascript
|
||||
// Options API - CORRECT: Use callback to access vm
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
user: null,
|
||||
loading: true
|
||||
}
|
||||
},
|
||||
|
||||
beforeRouteEnter(to, from, next) {
|
||||
// Fetch data before component exists
|
||||
fetchUser(to.params.id)
|
||||
.then(user => {
|
||||
// Pass callback to next() - receives component instance as 'vm'
|
||||
next(vm => {
|
||||
vm.user = user
|
||||
vm.loading = false
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
next(vm => {
|
||||
vm.error = error
|
||||
vm.loading = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Solution: Async beforeRouteEnter (Options API)
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
data() {
|
||||
return { userData: null }
|
||||
},
|
||||
|
||||
async beforeRouteEnter(to, from, next) {
|
||||
try {
|
||||
const user = await fetchUser(to.params.id)
|
||||
|
||||
// Still need callback for component access
|
||||
next(vm => {
|
||||
vm.userData = user
|
||||
})
|
||||
} catch (error) {
|
||||
// Redirect on error
|
||||
next('/error')
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Composition API Alternative
|
||||
|
||||
In Composition API with `<script setup>`, you cannot use `beforeRouteEnter` directly because the component instance is being set up. Use different patterns instead:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const user = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
// Option 1: Fetch in onMounted (after component exists)
|
||||
onMounted(async () => {
|
||||
user.value = await fetchUser(route.params.id)
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
// Option 2: Handle subsequent param changes
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
if (to.params.id !== from.params.id) {
|
||||
loading.value = true
|
||||
user.value = await fetchUser(to.params.id)
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Route-Level Data Fetching
|
||||
|
||||
For data that should load BEFORE navigation, use route-level guards:
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
const routes = [
|
||||
{
|
||||
path: '/users/:id',
|
||||
component: () => import('./UserProfile.vue'),
|
||||
beforeEnter: async (to, from) => {
|
||||
try {
|
||||
// Store data for component to access
|
||||
const user = await fetchUser(to.params.id)
|
||||
to.meta.user = user // Attach to route meta
|
||||
} catch (error) {
|
||||
return '/error'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- UserProfile.vue -->
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
// Access pre-fetched data from meta
|
||||
const user = route.meta.user
|
||||
</script>
|
||||
```
|
||||
|
||||
## Comparison of Navigation Guards
|
||||
|
||||
| Guard | Has `this`/component? | Can delay navigation? | Use case |
|
||||
|-------|----------------------|----------------------|----------|
|
||||
| beforeRouteEnter | NO (use next callback) | YES | Pre-fetch, redirect if data missing |
|
||||
| beforeRouteUpdate | YES | YES | React to param changes |
|
||||
| beforeRouteLeave | YES | YES | Unsaved changes warning |
|
||||
| Global beforeEach | NO | YES | Auth checks |
|
||||
| Route beforeEnter | NO | YES | Route-specific validation |
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **beforeRouteEnter runs before component creation** - No access to `this`
|
||||
2. **Use next(vm => ...) callback** - Only way to access component instance
|
||||
3. **Composition API has limitations** - Use onMounted or global guards instead
|
||||
4. **Consider route meta for pre-fetched data** - Clean separation of concerns
|
||||
5. **beforeRouteUpdate and beforeRouteLeave have component access** - They run when component exists
|
||||
|
||||
## Reference
|
||||
- [Vue Router In-Component Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html#in-component-guards)
|
||||
- [Vue Router Navigation Resolution Flow](https://router.vuejs.org/guide/advanced/navigation-guards.html#the-full-navigation-resolution-flow)
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
---
|
||||
title: Async Navigation Guards Require Proper Promise Handling
|
||||
impact: MEDIUM
|
||||
impactDescription: Unawaited promises in guards cause navigation to complete before async checks finish, allowing unauthorized access or missing data
|
||||
type: gotcha
|
||||
tags: [vue3, vue-router, navigation-guards, async, promises]
|
||||
---
|
||||
|
||||
# Async Navigation Guards Require Proper Promise Handling
|
||||
|
||||
**Impact: MEDIUM** - Navigation guards that perform async operations (API calls, auth checks) must properly handle promises. If you don't await async operations or return the promise, navigation completes before your check finishes, potentially allowing unauthorized access or navigating with incomplete data.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use async/await in navigation guards
|
||||
- [ ] Return the promise if not using async/await
|
||||
- [ ] Add loading states for long async operations
|
||||
- [ ] Implement timeouts for slow API calls
|
||||
- [ ] Handle errors to prevent navigation hanging
|
||||
|
||||
## The Problem
|
||||
|
||||
```javascript
|
||||
// WRONG: Not awaiting - navigation proceeds immediately
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
checkAuth() // This returns a Promise but we're not waiting!
|
||||
// Navigation continues before checkAuth completes
|
||||
}
|
||||
})
|
||||
|
||||
// WRONG: Async function but forgot return
|
||||
router.beforeEach(async (to, from) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
const isValid = await checkAuth()
|
||||
if (!isValid) {
|
||||
// This redirect might happen after navigation already completed!
|
||||
return '/login'
|
||||
}
|
||||
}
|
||||
// Missing return - implicitly returns undefined, allowing navigation
|
||||
})
|
||||
```
|
||||
|
||||
## Solution: Proper Async/Await Pattern
|
||||
|
||||
```javascript
|
||||
// CORRECT: Async function with proper returns
|
||||
router.beforeEach(async (to, from) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
try {
|
||||
const isAuthenticated = await checkAuth()
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
return { name: 'Error', params: { message: 'Authentication failed' } }
|
||||
}
|
||||
}
|
||||
// Explicitly return nothing to proceed
|
||||
return true
|
||||
})
|
||||
```
|
||||
|
||||
## Solution: Promise-Based Pattern (Alternative)
|
||||
|
||||
```javascript
|
||||
// CORRECT: Return promise explicitly
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
return checkAuth()
|
||||
.then(isAuthenticated => {
|
||||
if (!isAuthenticated) {
|
||||
return { name: 'Login' }
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Auth check failed:', error)
|
||||
return { name: 'Error' }
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Loading State During Async Guards
|
||||
|
||||
```javascript
|
||||
// app/composables/useNavigationLoading.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
export function useNavigationLoading() {
|
||||
return { isNavigating }
|
||||
}
|
||||
|
||||
export function setupNavigationLoading(router) {
|
||||
router.beforeEach(() => {
|
||||
isNavigating.value = true
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
isNavigating.value = false
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
isNavigating.value = false
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<script setup>
|
||||
import { useNavigationLoading } from '@/composables/useNavigationLoading'
|
||||
|
||||
const { isNavigating } = useNavigationLoading()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingBar v-if="isNavigating" />
|
||||
<router-view />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Timeout Pattern for Slow APIs
|
||||
|
||||
```javascript
|
||||
// CORRECT: Add timeout to prevent indefinite waiting
|
||||
function withTimeout(promise, ms = 5000) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Request timeout')), ms)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
try {
|
||||
const isValid = await withTimeout(checkAuth(), 5000)
|
||||
if (!isValid) {
|
||||
return '/login'
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message === 'Request timeout') {
|
||||
// Let user through but show warning
|
||||
console.warn('Auth check timed out')
|
||||
} else {
|
||||
return '/login'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Multiple Async Checks
|
||||
|
||||
```javascript
|
||||
// CORRECT: Run independent checks in parallel
|
||||
router.beforeEach(async (to, from) => {
|
||||
if (to.meta.requiresAuth && to.meta.requiresSubscription) {
|
||||
try {
|
||||
const [isAuthenticated, hasSubscription] = await Promise.all([
|
||||
checkAuth(),
|
||||
checkSubscription()
|
||||
])
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
if (!hasSubscription) {
|
||||
return '/subscribe'
|
||||
}
|
||||
} catch (error) {
|
||||
return '/error'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling Best Practices
|
||||
|
||||
```javascript
|
||||
router.beforeEach(async (to, from) => {
|
||||
try {
|
||||
// Your async logic here
|
||||
await performChecks(to)
|
||||
} catch (error) {
|
||||
// Always handle errors to prevent navigation from hanging
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
if (error.response?.status === 403) {
|
||||
return '/forbidden'
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR') {
|
||||
// Offline - maybe allow navigation but show warning
|
||||
return true
|
||||
}
|
||||
|
||||
// Unknown error - redirect to error page
|
||||
console.error('Navigation guard error:', error)
|
||||
return { name: 'Error', state: { error: error.message } }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Always await async operations** - Otherwise navigation proceeds immediately
|
||||
2. **Return values matter** - Return route to redirect, false to cancel, true/undefined to proceed
|
||||
3. **Handle all error cases** - Uncaught errors can hang navigation
|
||||
4. **Add timeouts** - Slow APIs shouldn't block navigation indefinitely
|
||||
5. **Show loading state** - Users need feedback during async checks
|
||||
6. **Parallelize independent checks** - Use Promise.all for better performance
|
||||
|
||||
## Reference
|
||||
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
|
||||
- [Vue Router Navigation Failures](https://router.vuejs.org/guide/advanced/navigation-failures.html)
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
---
|
||||
title: Navigation Guard Infinite Redirect Loops
|
||||
impact: HIGH
|
||||
impactDescription: Misconfigured navigation guards can trap users in infinite redirect loops, crashing the browser or making the app unusable
|
||||
type: gotcha
|
||||
tags: [vue3, vue-router, navigation-guards, redirect, debugging]
|
||||
---
|
||||
|
||||
# Navigation Guard Infinite Redirect Loops
|
||||
|
||||
**Impact: HIGH** - A common mistake in navigation guards is creating conditions that cause infinite redirects. Vue Router will detect this and show a warning, but in production, it can crash the browser or create a broken user experience.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always check if already on target route before redirecting
|
||||
- [ ] Test guard logic with all possible navigation scenarios
|
||||
- [ ] Add route meta to control which routes need protection
|
||||
- [ ] Use Vue Router devtools to debug redirect chains
|
||||
|
||||
## The Problem
|
||||
|
||||
```javascript
|
||||
// WRONG: Infinite loop - always redirects to login, even when on login!
|
||||
router.beforeEach((to, from) => {
|
||||
if (!isAuthenticated()) {
|
||||
return '/login' // Redirects to /login, which triggers guard again...
|
||||
}
|
||||
})
|
||||
|
||||
// WRONG: Circular redirect between two routes
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.path === '/dashboard' && !hasProfile()) {
|
||||
return '/profile'
|
||||
}
|
||||
if (to.path === '/profile' && !isVerified()) {
|
||||
return '/dashboard' // Back to dashboard, which goes to profile...
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Error you'll see:**
|
||||
```
|
||||
[Vue Router warn]: Detected an infinite redirection in a navigation guard when going from "/" to "/login". Aborting to avoid a Stack Overflow.
|
||||
```
|
||||
|
||||
## Solution 1: Exclude Target Route
|
||||
|
||||
```javascript
|
||||
// CORRECT: Don't redirect if already going to login
|
||||
router.beforeEach((to, from) => {
|
||||
if (!isAuthenticated() && to.path !== '/login') {
|
||||
return '/login'
|
||||
}
|
||||
})
|
||||
|
||||
// CORRECT: Use route name for cleaner check
|
||||
router.beforeEach((to, from) => {
|
||||
const publicPages = ['Login', 'Register', 'ForgotPassword']
|
||||
|
||||
if (!isAuthenticated() && !publicPages.includes(to.name)) {
|
||||
return { name: 'Login' }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Solution 2: Use Route Meta Fields
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/public',
|
||||
name: 'PublicPage',
|
||||
component: PublicPage,
|
||||
meta: { requiresAuth: false }
|
||||
}
|
||||
]
|
||||
|
||||
// Guard checks meta field
|
||||
router.beforeEach((to, from) => {
|
||||
// Only redirect if route requires auth
|
||||
if (to.meta.requiresAuth && !isAuthenticated()) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Solution 3: Handle Redirect Chains Carefully
|
||||
|
||||
```javascript
|
||||
// CORRECT: Break potential circular redirects
|
||||
router.beforeEach((to, from) => {
|
||||
// Prevent redirect loops by tracking redirect depth
|
||||
const redirectCount = to.query._redirectCount || 0
|
||||
|
||||
if (redirectCount > 3) {
|
||||
console.error('Too many redirects, stopping at:', to.path)
|
||||
return '/error' // Escape hatch
|
||||
}
|
||||
|
||||
if (needsRedirect(to)) {
|
||||
return {
|
||||
path: getRedirectTarget(to),
|
||||
query: { ...to.query, _redirectCount: redirectCount + 1 }
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Solution 4: Centralized Redirect Logic
|
||||
|
||||
```javascript
|
||||
// guards/auth.js
|
||||
export function createAuthGuard(router) {
|
||||
const publicRoutes = new Set(['Login', 'Register', 'ForgotPassword', 'ResetPassword'])
|
||||
const guestOnlyRoutes = new Set(['Login', 'Register'])
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
const isPublic = publicRoutes.has(to.name)
|
||||
const isGuestOnly = guestOnlyRoutes.has(to.name)
|
||||
const isLoggedIn = isAuthenticated()
|
||||
|
||||
// Not logged in, trying to access protected route
|
||||
if (!isLoggedIn && !isPublic) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// Logged in, trying to access guest-only route (like login page)
|
||||
if (isLoggedIn && isGuestOnly) {
|
||||
return { name: 'Dashboard' }
|
||||
}
|
||||
|
||||
// All other cases: proceed
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Redirect Loops
|
||||
|
||||
```javascript
|
||||
// Add logging to understand the redirect chain
|
||||
router.beforeEach((to, from) => {
|
||||
console.log(`Navigation: ${from.path} -> ${to.path}`)
|
||||
console.log('Auth state:', isAuthenticated())
|
||||
console.log('Route meta:', to.meta)
|
||||
|
||||
// Your guard logic here
|
||||
})
|
||||
|
||||
// Or use afterEach for confirmed navigations
|
||||
router.afterEach((to, from) => {
|
||||
console.log(`Navigated: ${from.path} -> ${to.path}`)
|
||||
})
|
||||
```
|
||||
|
||||
## Common Redirect Loop Patterns
|
||||
|
||||
| Pattern | Problem | Fix |
|
||||
|---------|---------|-----|
|
||||
| Auth check without exclusion | Login redirects to login | Exclude `/login` from check |
|
||||
| Role-based with circular deps | Admin -> User -> Admin | Use single source of truth for role requirements |
|
||||
| Onboarding flow | Step 1 -> Step 2 -> Step 1 | Track completion state properly |
|
||||
| Redirect query handling | Reading redirect creates new redirect | Process redirect only once |
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Always exclude the target route** - Never redirect to a route that would trigger the same redirect
|
||||
2. **Use route meta fields** - Cleaner than path string comparisons
|
||||
3. **Test edge cases** - Direct URL access, refresh, back button
|
||||
4. **Add logging during development** - Helps trace redirect chains
|
||||
5. **Have an escape hatch** - Error page or max redirect count
|
||||
|
||||
## Reference
|
||||
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
|
||||
- [Vue Router Route Meta Fields](https://router.vuejs.org/guide/advanced/meta.html)
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
---
|
||||
title: Vue Router Navigation Guard next() Function Deprecated
|
||||
impact: HIGH
|
||||
impactDescription: Using the deprecated next() function incorrectly causes navigation to hang, infinite loops, or silent failures
|
||||
type: gotcha
|
||||
tags: [vue3, vue-router, navigation-guards, migration, async]
|
||||
---
|
||||
|
||||
# Vue Router Navigation Guard next() Function Deprecated
|
||||
|
||||
**Impact: HIGH** - The third `next()` argument in navigation guards is deprecated in Vue Router 4. While still supported for backward compatibility, using it incorrectly is one of the most common sources of bugs: calling it multiple times, forgetting to call it, or calling it conditionally without proper logic.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Refactor guards to use return-based syntax instead of next()
|
||||
- [ ] Remove all next() calls from navigation guards
|
||||
- [ ] Use async/await pattern for asynchronous checks
|
||||
- [ ] Return false to cancel, return route to redirect, return nothing to proceed
|
||||
|
||||
## The Problem
|
||||
|
||||
```javascript
|
||||
// WRONG: Using deprecated next() function
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (!isAuthenticated) {
|
||||
next('/login') // Easy to forget this call
|
||||
}
|
||||
// BUG: next() not called when authenticated - navigation hangs!
|
||||
})
|
||||
|
||||
// WRONG: Multiple next() calls
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (!isAuthenticated) {
|
||||
next('/login')
|
||||
}
|
||||
next() // BUG: Called twice when not authenticated!
|
||||
})
|
||||
|
||||
// WRONG: next() in async code without proper handling
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const user = await fetchUser()
|
||||
if (!user) {
|
||||
next('/login')
|
||||
}
|
||||
next() // Still gets called even after redirect!
|
||||
})
|
||||
```
|
||||
|
||||
## Solution: Use Return-Based Guards
|
||||
|
||||
```javascript
|
||||
// CORRECT: Return-based syntax (modern Vue Router 4+)
|
||||
router.beforeEach((to, from) => {
|
||||
if (!isAuthenticated) {
|
||||
return '/login' // Redirect
|
||||
}
|
||||
// Return nothing (undefined) to proceed
|
||||
})
|
||||
|
||||
// CORRECT: Return false to cancel navigation
|
||||
router.beforeEach((to, from) => {
|
||||
if (hasUnsavedChanges) {
|
||||
return false // Cancel navigation
|
||||
}
|
||||
})
|
||||
|
||||
// CORRECT: Async with return-based syntax
|
||||
router.beforeEach(async (to, from) => {
|
||||
const user = await fetchUser()
|
||||
if (!user) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
// Proceed with navigation
|
||||
})
|
||||
```
|
||||
|
||||
## Return Values Explained
|
||||
|
||||
```javascript
|
||||
router.beforeEach((to, from) => {
|
||||
// Return nothing/undefined - allow navigation
|
||||
return
|
||||
|
||||
// Return false - cancel navigation, stay on current route
|
||||
return false
|
||||
|
||||
// Return string path - redirect to path
|
||||
return '/login'
|
||||
|
||||
// Return route object - redirect with full control
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
|
||||
// Return Error - cancel and trigger router.onError()
|
||||
return new Error('Navigation cancelled')
|
||||
})
|
||||
```
|
||||
|
||||
## If You Must Use next() (Legacy Code)
|
||||
|
||||
If maintaining legacy code that uses `next()`, follow these rules strictly:
|
||||
|
||||
```javascript
|
||||
// CORRECT: Exactly one next() call per code path
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (!isAuthenticated) {
|
||||
next('/login')
|
||||
return // CRITICAL: Exit after calling next()
|
||||
}
|
||||
|
||||
if (!hasPermission(to)) {
|
||||
next('/forbidden')
|
||||
return // CRITICAL: Exit after calling next()
|
||||
}
|
||||
|
||||
next() // Only reached if all checks pass
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
```javascript
|
||||
router.beforeEach(async (to, from) => {
|
||||
try {
|
||||
await validateAccess(to)
|
||||
// Proceed
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
return '/login'
|
||||
}
|
||||
if (error.status === 403) {
|
||||
return '/forbidden'
|
||||
}
|
||||
// Log error and proceed anyway (or return false)
|
||||
console.error('Access validation failed:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Prefer return-based syntax** - Cleaner, less error-prone, modern standard
|
||||
2. **next() must be called exactly once** - If using legacy syntax, ensure single call per path
|
||||
3. **Always return/exit after redirect** - Prevent multiple navigation actions
|
||||
4. **Async guards work naturally** - Just return the redirect route or nothing
|
||||
5. **Test all code paths** - Each branch must result in either return or next()
|
||||
|
||||
## Reference
|
||||
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
|
||||
- [RFC: Remove next() from Navigation Guards](https://github.com/vuejs/rfcs/discussions/302)
|
||||
-181
@@ -1,181 +0,0 @@
|
||||
---
|
||||
title: Route Param Changes Do Not Trigger Lifecycle Hooks
|
||||
impact: HIGH
|
||||
impactDescription: Navigating between routes with different params reuses the component instance, skipping created/mounted hooks and leaving stale data
|
||||
type: gotcha
|
||||
tags: [vue3, vue-router, lifecycle, params, reactivity]
|
||||
---
|
||||
|
||||
# Route Param Changes Do Not Trigger Lifecycle Hooks
|
||||
|
||||
**Impact: HIGH** - When navigating between routes that use the same component (e.g., `/users/1` to `/users/2`), Vue Router reuses the existing component instance for performance. This means `onMounted`, `created`, and other lifecycle hooks do NOT fire, leaving you with stale data from the previous route.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `watch` on route params for data fetching
|
||||
- [ ] Or use `onBeforeRouteUpdate` in-component guard
|
||||
- [ ] Or use `:key="route.params.id"` to force re-creation (less efficient)
|
||||
- [ ] Never rely solely on `onMounted` for route-param-dependent data
|
||||
|
||||
## The Problem
|
||||
|
||||
```vue
|
||||
<!-- UserProfile.vue - Used for /users/:id -->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const user = ref(null)
|
||||
|
||||
// BUG: Only runs once when component first mounts!
|
||||
// Navigating from /users/1 to /users/2 does NOT trigger this
|
||||
onMounted(async () => {
|
||||
user.value = await fetchUser(route.params.id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Still shows User 1 data when navigating to /users/2! -->
|
||||
<h1>{{ user?.name }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Scenario:**
|
||||
1. Visit `/users/1` - Component mounts, fetches User 1 data
|
||||
2. Navigate to `/users/2` - Component is REUSED, onMounted doesn't run
|
||||
3. UI still shows User 1's data!
|
||||
|
||||
## Solution 1: Watch Route Params (Recommended)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const user = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Watch for param changes - handles both initial load and navigation
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (newId) => {
|
||||
loading.value = true
|
||||
user.value = await fetchUser(newId)
|
||||
loading.value = false
|
||||
},
|
||||
{ immediate: true } // Run immediately for initial load
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Solution 2: Use onBeforeRouteUpdate Guard
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const user = ref(null)
|
||||
|
||||
async function loadUser(id) {
|
||||
user.value = await fetchUser(id)
|
||||
}
|
||||
|
||||
// Initial load
|
||||
onMounted(() => loadUser(route.params.id))
|
||||
|
||||
// Handle param changes within same route
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
if (to.params.id !== from.params.id) {
|
||||
await loadUser(to.params.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Solution 3: Force Component Re-creation with Key
|
||||
|
||||
```vue
|
||||
<!-- App.vue or parent component -->
|
||||
<template>
|
||||
<router-view :key="$route.fullPath" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Tradeoffs:**
|
||||
- Simple but less performant
|
||||
- Destroys and recreates component on every param change
|
||||
- Loses component state
|
||||
- Use only when component state should reset completely
|
||||
|
||||
## Solution 4: Composable for Route-Reactive Data
|
||||
|
||||
```javascript
|
||||
// composables/useRouteData.js
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export function useRouteData(paramName, fetcher) {
|
||||
const route = useRoute()
|
||||
const data = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
watch(
|
||||
() => route.params[paramName],
|
||||
async (id) => {
|
||||
if (!id) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
data.value = await fetcher(id)
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { data, loading, error }
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Usage in component -->
|
||||
<script setup>
|
||||
import { useRouteData } from '@/composables/useRouteData'
|
||||
import { fetchUser } from '@/api/users'
|
||||
|
||||
const { data: user, loading, error } = useRouteData('id', fetchUser)
|
||||
</script>
|
||||
```
|
||||
|
||||
## What Triggers vs. What Doesn't
|
||||
|
||||
| Navigation Type | Lifecycle Hooks | beforeRouteUpdate | Watch on params |
|
||||
|----------------|-----------------|-------------------|-----------------|
|
||||
| `/users/1` to `/posts/1` | YES | NO | YES |
|
||||
| `/users/1` to `/users/2` | NO | YES | YES |
|
||||
| `/users/1?tab=a` to `/users/1?tab=b` | NO | YES | NO (different watch) |
|
||||
| `/users/1` to `/users/1` (same) | NO | NO | NO |
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Same route, different params = same component instance** - This is a performance optimization
|
||||
2. **Lifecycle hooks only fire once** - When component first mounts
|
||||
3. **Use `watch` with `immediate: true`** - Covers both initial load and updates
|
||||
4. **`onBeforeRouteUpdate` is navigation-aware** - Good for data that must load before view updates
|
||||
5. **`:key="route.fullPath"` is a sledgehammer** - Use only when necessary
|
||||
|
||||
## Reference
|
||||
- [Vue Router Dynamic Route Matching](https://router.vuejs.org/guide/essentials/dynamic-matching.html#reacting-to-params-changes)
|
||||
- [Vue School: Reacting to Param Changes](https://vueschool.io/lessons/reacting-to-param-changes)
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
title: Simple Hash Routing Requires Event Listener Cleanup
|
||||
impact: MEDIUM
|
||||
impactDescription: When implementing basic routing without Vue Router, forgetting to remove hashchange listeners causes memory leaks and multiple handler execution
|
||||
type: gotcha
|
||||
tags: [vue3, routing, events, memory-leak, cleanup]
|
||||
---
|
||||
|
||||
# Simple Hash Routing Requires Event Listener Cleanup
|
||||
|
||||
**Impact: MEDIUM** - When implementing basic client-side routing without Vue Router (using hash-based routing with `hashchange` events), you must clean up event listeners when the component unmounts. Failure to do so causes memory leaks and can result in multiple handlers firing after the component is recreated.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Store event listener reference for cleanup
|
||||
- [ ] Use onUnmounted to remove event listener
|
||||
- [ ] Consider using Vue Router instead for production apps
|
||||
- [ ] Test component mount/unmount cycles
|
||||
|
||||
## The Problem
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Home from './Home.vue'
|
||||
import About from './About.vue'
|
||||
|
||||
const routes = {
|
||||
'/': Home,
|
||||
'/about': About
|
||||
}
|
||||
|
||||
const currentPath = ref(window.location.hash)
|
||||
|
||||
// BUG: Event listener is never removed!
|
||||
// Each time this component mounts, a NEW listener is added
|
||||
// After mounting 5 times, you have 5 listeners running
|
||||
window.addEventListener('hashchange', () => {
|
||||
currentPath.value = window.location.hash
|
||||
})
|
||||
|
||||
const currentView = computed(() => {
|
||||
return routes[currentPath.value.slice(1) || '/']
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Component mounts, adds listener
|
||||
2. Component unmounts (e.g., route change, v-if toggle)
|
||||
3. Component mounts again, adds ANOTHER listener
|
||||
4. Now TWO listeners respond to each hash change
|
||||
5. Eventually causes performance issues and memory leaks
|
||||
|
||||
## Solution: Proper Cleanup with onUnmounted
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import Home from './Home.vue'
|
||||
import About from './About.vue'
|
||||
import NotFound from './NotFound.vue'
|
||||
|
||||
const routes = {
|
||||
'/': Home,
|
||||
'/about': About
|
||||
}
|
||||
|
||||
const currentPath = ref(window.location.hash)
|
||||
|
||||
// Store handler reference for cleanup
|
||||
function handleHashChange() {
|
||||
currentPath.value = window.location.hash
|
||||
}
|
||||
|
||||
// Add listener
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
|
||||
// CRITICAL: Remove listener on unmount
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hashchange', handleHashChange)
|
||||
})
|
||||
|
||||
const currentView = computed(() => {
|
||||
return routes[currentPath.value.slice(1) || '/'] || NotFound
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Solution: Using Options API
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import Home from './Home.vue'
|
||||
import About from './About.vue'
|
||||
import NotFound from './NotFound.vue'
|
||||
|
||||
const routes = {
|
||||
'/': Home,
|
||||
'/about': About
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
currentPath: window.location.hash
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentView() {
|
||||
return routes[this.currentPath.slice(1) || '/'] || NotFound
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Store bound handler for cleanup
|
||||
this.hashHandler = () => {
|
||||
this.currentPath = window.location.hash
|
||||
}
|
||||
window.addEventListener('hashchange', this.hashHandler)
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
// Clean up
|
||||
window.removeEventListener('hashchange', this.hashHandler)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Solution: Composable for Reusable Hash Routing
|
||||
|
||||
```javascript
|
||||
// composables/useHashRouter.js
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export function useHashRouter(routes, notFoundComponent = null) {
|
||||
const currentPath = ref(window.location.hash)
|
||||
|
||||
function handleHashChange() {
|
||||
currentPath.value = window.location.hash
|
||||
}
|
||||
|
||||
// Setup
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
|
||||
// Cleanup - handled automatically when component unmounts
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hashchange', handleHashChange)
|
||||
})
|
||||
|
||||
const currentView = computed(() => {
|
||||
const path = currentPath.value.slice(1) || '/'
|
||||
return routes[path] || notFoundComponent
|
||||
})
|
||||
|
||||
function navigate(path) {
|
||||
window.location.hash = path
|
||||
}
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
currentView,
|
||||
navigate
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Usage -->
|
||||
<script setup>
|
||||
import { useHashRouter } from '@/composables/useHashRouter'
|
||||
import Home from './Home.vue'
|
||||
import About from './About.vue'
|
||||
import NotFound from './NotFound.vue'
|
||||
|
||||
const { currentView } = useHashRouter({
|
||||
'/': Home,
|
||||
'/about': About
|
||||
}, NotFound)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="currentView" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## When to Use Simple Routing vs Vue Router
|
||||
|
||||
| Use Simple Hash Routing | Use Vue Router |
|
||||
|------------------------|----------------|
|
||||
| Learning/prototyping | Production apps |
|
||||
| Very simple apps (2-3 pages) | Nested routes needed |
|
||||
| No build step available | Navigation guards needed |
|
||||
| Bundle size critical | Lazy loading needed |
|
||||
| Static hosting only | History mode (clean URLs) |
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Always clean up event listeners** - Use onUnmounted or beforeUnmount
|
||||
2. **Store handler reference** - Anonymous functions can't be removed
|
||||
3. **Consider Vue Router for real apps** - It handles cleanup automatically
|
||||
4. **Test unmount scenarios** - v-if toggling, hot module replacement
|
||||
5. **Composables help encapsulate cleanup logic** - Reusable and automatic
|
||||
|
||||
## Reference
|
||||
- [Vue.js Routing Documentation](https://vuejs.org/guide/scaling-up/routing.html)
|
||||
- [Vue Router Official Library](https://router.vuejs.org/)
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: Use Vue Router Library for Production Applications
|
||||
impact: LOW
|
||||
impactDescription: Simple hash routing lacks essential features for production SPAs; Vue Router provides navigation guards, lazy loading, and proper history management
|
||||
type: best-practice
|
||||
tags: [vue3, vue-router, spa, production, architecture]
|
||||
---
|
||||
|
||||
# Use Vue Router Library for Production Applications
|
||||
|
||||
**Impact: LOW** - While you can implement basic routing with hash changes and dynamic components, the official Vue Router library should be used for any production single-page application. It provides essential features like navigation guards, nested routes, lazy loading, and proper browser history integration that are tedious and error-prone to implement manually.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Install Vue Router for production SPAs
|
||||
- [ ] Use simple routing only for learning or tiny prototypes
|
||||
- [ ] Leverage built-in features: guards, lazy loading, meta fields
|
||||
- [ ] Consider router-based state and data loading patterns
|
||||
|
||||
## When Simple Routing is Acceptable
|
||||
|
||||
```vue
|
||||
<!-- Only for: learning, prototypes, or micro-apps with 2-3 pages -->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Home from './Home.vue'
|
||||
import About from './About.vue'
|
||||
|
||||
const routes = { '/': Home, '/about': About }
|
||||
const currentPath = ref(window.location.hash.slice(1) || '/')
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
currentPath.value = window.location.hash.slice(1) || '/'
|
||||
})
|
||||
|
||||
const currentView = computed(() => routes[currentPath.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav>
|
||||
<a href="#/">Home</a>
|
||||
<a href="#/about">About</a>
|
||||
</nav>
|
||||
<component :is="currentView" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Why Vue Router for Production
|
||||
|
||||
### Features You'd Have to Implement Manually
|
||||
|
||||
| Feature | Simple Routing | Vue Router |
|
||||
|---------|---------------|------------|
|
||||
| Navigation guards | Manual, error-prone | Built-in, composable |
|
||||
| Nested routes | Complex to implement | Native support |
|
||||
| Route params | Parse manually | Automatic extraction |
|
||||
| Lazy loading | DIY with dynamic imports | Built-in with code splitting |
|
||||
| History mode (clean URLs) | Requires server config + manual | Built-in |
|
||||
| Scroll behavior | Manual | Configurable |
|
||||
| Route transitions | DIY | Integrated with Transition |
|
||||
| Active link styling | Manual class toggling | `router-link-active` class |
|
||||
| Programmatic navigation | `location.hash = ...` | `router.push()`, `router.replace()` |
|
||||
| Route meta fields | N/A | Built-in |
|
||||
|
||||
## Production Setup with Vue Router
|
||||
|
||||
```javascript
|
||||
// router/index.js
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue'), // Lazy loaded
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/users/:id',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/views/UserProfile.vue'),
|
||||
props: true // Pass params as props
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
// Global navigation guard
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.meta.requiresAuth && !isAuthenticated()) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
```javascript
|
||||
// main.js
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.mount('#app')
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<nav>
|
||||
<router-link to="/">Home</router-link>
|
||||
<router-link to="/dashboard">Dashboard</router-link>
|
||||
</nav>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Modern Vue Router Features (2025+)
|
||||
|
||||
```javascript
|
||||
// Data Loading API (Vue Router 4.2+)
|
||||
const routes = [
|
||||
{
|
||||
path: '/users/:id',
|
||||
component: UserProfile,
|
||||
// Load data at route level
|
||||
loader: async (route) => {
|
||||
return { user: await fetchUser(route.params.id) }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// View Transitions API integration
|
||||
const router = createRouter({
|
||||
// Enable native browser view transitions
|
||||
// Requires browser support (Chrome 111+)
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Use Vue Router for any app beyond a prototype** - The features are essential
|
||||
2. **Simple routing is for learning** - Understand the concepts, then use the library
|
||||
3. **Lazy loading is critical for bundle size** - Vue Router makes it trivial
|
||||
4. **Navigation guards prevent security issues** - Hard to get right manually
|
||||
5. **History mode requires Vue Router** - Clean URLs need proper handling
|
||||
6. **New features keep coming** - Data Loading API, View Transitions
|
||||
|
||||
## Reference
|
||||
- [Vue.js Routing Guide](https://vuejs.org/guide/scaling-up/routing.html)
|
||||
- [Vue Router Documentation](https://router.vuejs.org/)
|
||||
- [Vue Router Getting Started](https://router.vuejs.org/guide/)
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 hyf0, SerKo <https://github.com/serkodev>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: vue-testing-best-practices
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
author: github.com/vuejs-ai
|
||||
description: Use for Vue.js testing. Covers Vitest, Vue Test Utils, component testing, mocking, testing patterns, and Playwright for E2E testing.
|
||||
---
|
||||
|
||||
Vue.js testing best practices, patterns, and common gotchas.
|
||||
|
||||
### Testing
|
||||
- Setting up test infrastructure for Vue 3 projects → See [testing-vitest-recommended-for-vue](reference/testing-vitest-recommended-for-vue.md)
|
||||
- Tests keep breaking when refactoring component internals → See [testing-component-blackbox-approach](reference/testing-component-blackbox-approach.md)
|
||||
- Tests fail intermittently with race conditions → See [testing-async-await-flushpromises](reference/testing-async-await-flushpromises.md)
|
||||
- Composables using lifecycle hooks or inject fail to test → See [testing-composables-helper-wrapper](reference/testing-composables-helper-wrapper.md)
|
||||
- Getting "injection Symbol(pinia) not found" errors in tests → See [testing-pinia-store-setup](reference/testing-pinia-store-setup.md)
|
||||
- Components with async setup won't render in tests → See [testing-suspense-async-components](reference/testing-suspense-async-components.md)
|
||||
- Snapshot tests keep passing despite broken functionality → See [testing-no-snapshot-only](reference/testing-no-snapshot-only.md)
|
||||
- Choosing end-to-end testing framework for Vue apps → See [testing-e2e-playwright-recommended](reference/testing-e2e-playwright-recommended.md)
|
||||
- Tests need to verify computed styles or real DOM events → See [testing-browser-vs-node-runners](reference/testing-browser-vs-node-runners.md)
|
||||
- Testing components created with defineAsyncComponent fails → See [async-component-testing](reference/async-component-testing.md)
|
||||
- Teleported modal content can't be found in wrapper queries → See [teleport-testing-complexity](reference/teleport-testing-complexity.md)
|
||||
|
||||
## Reference
|
||||
|
||||
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
|
||||
- [Vue Test Utils](https://test-utils.vuejs.org/)
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
@@ -1,5 +0,0 @@
|
||||
# Sync Info
|
||||
|
||||
- **Source:** `vendor/vuejs-ai/skills/vue-testing-best-practices`
|
||||
- **Git SHA:** `f3dd1bf4d3ac78331bdc903e4519d561c538ca6a`
|
||||
- **Synced:** 2026-03-16
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
title: Use flushPromises for Testing Async Components
|
||||
impact: HIGH
|
||||
impactDescription: Without awaiting async operations, tests make assertions before the component has rendered, causing false negatives
|
||||
type: gotcha
|
||||
tags: [vue3, testing, async, defineAsyncComponent, flushPromises, vitest]
|
||||
---
|
||||
|
||||
# Use flushPromises for Testing Async Components
|
||||
|
||||
**Impact: HIGH** - When testing async components created with `defineAsyncComponent`, you must use `await flushPromises()` to ensure the component has loaded before making assertions. Vue updates asynchronously, so tests that don't account for this will make assertions before the component has rendered.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `async/await` in test functions for async components
|
||||
- [ ] Call `await flushPromises()` after mounting async components
|
||||
- [ ] Test loading states by making assertions before `flushPromises()`
|
||||
- [ ] Test error states using rejected promises in `defineAsyncComponent`
|
||||
- [ ] Use `trigger()` with `await` as it returns a Promise
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const AsyncWidget = defineAsyncComponent(() =>
|
||||
import('./Widget.vue')
|
||||
)
|
||||
|
||||
test('renders async component', () => {
|
||||
const wrapper = mount(AsyncWidget)
|
||||
|
||||
// FAILS: Component hasn't loaded yet
|
||||
expect(wrapper.text()).toContain('Widget Content')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineAsyncComponent, nextTick } from 'vue'
|
||||
|
||||
const AsyncWidget = defineAsyncComponent(() =>
|
||||
import('./Widget.vue')
|
||||
)
|
||||
|
||||
test('renders async component', async () => {
|
||||
const wrapper = mount(AsyncWidget)
|
||||
|
||||
// Wait for async component to load
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Widget Content')
|
||||
})
|
||||
|
||||
test('shows loading state initially', async () => {
|
||||
const AsyncWithLoading = defineAsyncComponent({
|
||||
loader: () => import('./Widget.vue'),
|
||||
loadingComponent: { template: '<div>Loading...</div>' },
|
||||
delay: 0
|
||||
})
|
||||
|
||||
const wrapper = mount(AsyncWithLoading)
|
||||
|
||||
// Check loading state immediately
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Wait for component to load
|
||||
await flushPromises()
|
||||
|
||||
// Check final state
|
||||
expect(wrapper.text()).toContain('Widget Content')
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with Suspense
|
||||
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { Suspense, defineAsyncComponent, h } from 'vue'
|
||||
|
||||
const AsyncWidget = defineAsyncComponent(() =>
|
||||
import('./Widget.vue')
|
||||
)
|
||||
|
||||
test('renders async component with Suspense', async () => {
|
||||
const wrapper = mount({
|
||||
components: { AsyncWidget },
|
||||
template: `
|
||||
<Suspense>
|
||||
<AsyncWidget />
|
||||
<template #fallback>
|
||||
<div>Loading...</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
`
|
||||
})
|
||||
|
||||
// Initially shows fallback
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Wait for async resolution
|
||||
await flushPromises()
|
||||
|
||||
// Now shows actual content
|
||||
expect(wrapper.text()).toContain('Widget Content')
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Error States
|
||||
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
test('shows error component on load failure', async () => {
|
||||
const AsyncWithError = defineAsyncComponent({
|
||||
loader: () => Promise.reject(new Error('Failed to load')),
|
||||
errorComponent: { template: '<div>Error loading component</div>' }
|
||||
})
|
||||
|
||||
const wrapper = mount(AsyncWithError)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Error loading component')
|
||||
})
|
||||
```
|
||||
|
||||
## Utilities Reference
|
||||
|
||||
| Utility | Purpose |
|
||||
|---------|---------|
|
||||
| `await flushPromises()` | Resolves all pending promises |
|
||||
| `await nextTick()` | Waits for Vue's next DOM update cycle |
|
||||
| `await wrapper.trigger('click')` | Triggers event and waits for update |
|
||||
|
||||
## Dynamic Import Handling
|
||||
|
||||
**Note:** Dynamic imports (`import('./File.vue')`) may require additional handling beyond `flushPromises()` in test environments. Test runners like Vitest handle module resolution differently than runtime bundlers, which can cause timing issues with dynamic imports. If `flushPromises()` alone doesn't resolve the component, consider:
|
||||
|
||||
- Mocking the dynamic import to return the component synchronously
|
||||
- Using multiple `await flushPromises()` calls in sequence
|
||||
- Wrapping assertions in `waitFor()` or retry utilities
|
||||
- Configuring your test runner's module resolution settings
|
||||
|
||||
```javascript
|
||||
// If flushPromises() isn't sufficient, mock the import
|
||||
vi.mock('./Widget.vue', () => ({
|
||||
default: { template: '<div>Widget Content</div>' }
|
||||
}))
|
||||
|
||||
// Or use multiple flush calls for nested async operations
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense)
|
||||
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)
|
||||
@@ -1,158 +0,0 @@
|
||||
---
|
||||
title: Teleported Content Requires Special Testing Approach
|
||||
impact: MEDIUM
|
||||
impactDescription: Vue Test Utils cannot find teleported content using standard wrapper.find() methods
|
||||
type: gotcha
|
||||
tags: [vue3, teleport, testing, vue-test-utils]
|
||||
---
|
||||
|
||||
# Teleported Content Requires Special Testing Approach
|
||||
|
||||
**Impact: MEDIUM** - Vue Test Utils scopes queries to the mounted component. Teleported content renders outside the component's DOM tree, so `wrapper.find()` cannot locate it. This leads to failing tests and confusion.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Stub Teleport in unit tests to keep content in component tree
|
||||
- [ ] Use `document.body` queries for integration tests with real Teleport
|
||||
- [ ] Consider using `getComponent()` instead of DOM queries for teleported components
|
||||
|
||||
**Problem - Standard Testing Fails:**
|
||||
```vue
|
||||
<!-- Modal.vue -->
|
||||
<template>
|
||||
<button @click="open = true">Open</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal" data-testid="modal">
|
||||
<input type="text" data-testid="modal-input" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
```ts
|
||||
// Modal.spec.ts - BROKEN
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
test('modal input exists', async () => {
|
||||
const wrapper = mount(Modal)
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// FAILS: Teleported content is not in wrapper's DOM tree
|
||||
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
**Solution 1 - Stub Teleport:**
|
||||
```ts
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
test('modal input exists', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
global: {
|
||||
stubs: {
|
||||
// Stub teleport to render content inline
|
||||
Teleport: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Works: Content renders inside wrapper
|
||||
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
**Solution 2 - Query Document Body:**
|
||||
```ts
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
test('modal renders to body', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
attachTo: document.body // Required for Teleport to work
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Query the actual DOM
|
||||
const modal = document.querySelector('[data-testid="modal"]')
|
||||
expect(modal).toBeTruthy()
|
||||
|
||||
const input = document.querySelector('[data-testid="modal-input"]')
|
||||
expect(input).toBeTruthy()
|
||||
|
||||
// Cleanup
|
||||
wrapper.unmount()
|
||||
})
|
||||
```
|
||||
|
||||
**Solution 3 - Custom Teleport Stub with Content Access:**
|
||||
```ts
|
||||
import { mount, config } from '@vue/test-utils'
|
||||
import { h, Teleport } from 'vue'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
// Custom stub that renders content in a testable way
|
||||
const TeleportStub = {
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', { class: 'teleport-stub' }, slots.default?.())
|
||||
}
|
||||
}
|
||||
|
||||
test('modal with custom stub', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: TeleportStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Content is inside .teleport-stub
|
||||
expect(wrapper.find('.teleport-stub [data-testid="modal-input"]').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Vue Final Modal and UI Libraries
|
||||
|
||||
Libraries like Vue Final Modal use Teleport internally, causing test failures:
|
||||
|
||||
```ts
|
||||
// Problem: Vue Final Modal teleports to body
|
||||
import { VueFinalModal } from 'vue-final-modal'
|
||||
|
||||
test('modal content', async () => {
|
||||
const wrapper = mount(MyComponent, {
|
||||
global: {
|
||||
stubs: {
|
||||
// Stub the modal component to avoid teleport issues
|
||||
VueFinalModal: true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## E2E Testing (Cypress, Playwright)
|
||||
|
||||
E2E tests query the real DOM, so Teleport works naturally:
|
||||
|
||||
```ts
|
||||
// Cypress
|
||||
it('opens modal', () => {
|
||||
cy.visit('/page-with-modal')
|
||||
cy.get('button').click()
|
||||
|
||||
// Works: Cypress queries the real DOM
|
||||
cy.get('[data-testid="modal"]').should('be.visible')
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue Test Utils - Teleport](https://test-utils.vuejs.org/guide/advanced/teleport)
|
||||
- [Vue Test Utils - Stubs](https://test-utils.vuejs.org/guide/advanced/stubs-shallow-mount)
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: Properly Handle Async Updates with nextTick and flushPromises
|
||||
impact: HIGH
|
||||
impactDescription: Race conditions and flaky tests occur when async DOM updates or API calls complete after assertions run
|
||||
type: gotcha
|
||||
tags: [vue3, testing, async, flushPromises, nextTick, vitest, vue-test-utils, race-condition]
|
||||
---
|
||||
|
||||
# Properly Handle Async Updates with nextTick and flushPromises
|
||||
|
||||
**Impact: HIGH** - Vue updates the DOM asynchronously. Without properly awaiting these updates, tests may assert against stale DOM state, causing intermittent failures and false negatives.
|
||||
|
||||
Use `await` with triggers and `setValue`, use `nextTick` for reactive updates, and use `flushPromises` for external async operations like API calls.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always await `trigger()` and `setValue()` calls
|
||||
- [ ] Use `await nextTick()` after programmatic reactive state changes
|
||||
- [ ] Use `await flushPromises()` for external async operations (API calls, timers)
|
||||
- [ ] Don't chain multiple `nextTick` calls - use `flushPromises` instead
|
||||
- [ ] Consider using `waitFor` from testing-library for polling assertions
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SearchComponent from './SearchComponent.vue'
|
||||
|
||||
// BAD: Not awaiting trigger - assertion runs before DOM updates
|
||||
test('search filters results', () => {
|
||||
const wrapper = mount(SearchComponent)
|
||||
|
||||
wrapper.find('input').setValue('vue') // Missing await!
|
||||
wrapper.find('button').trigger('click') // Missing await!
|
||||
|
||||
// This assertion likely fails - DOM hasn't updated yet
|
||||
expect(wrapper.findAll('.result').length).toBe(3)
|
||||
})
|
||||
|
||||
// BAD: Using nextTick for API calls
|
||||
test('loads data from API', async () => {
|
||||
const wrapper = mount(DataLoader)
|
||||
|
||||
await nextTick() // This won't wait for the API call!
|
||||
|
||||
// Assertion runs before fetch completes
|
||||
expect(wrapper.find('.data').text()).toBe('Loaded data')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import SearchComponent from './SearchComponent.vue'
|
||||
import DataLoader from './DataLoader.vue'
|
||||
|
||||
// CORRECT: Await trigger and setValue
|
||||
test('search filters results', async () => {
|
||||
const wrapper = mount(SearchComponent)
|
||||
|
||||
await wrapper.find('input').setValue('vue')
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.findAll('.result').length).toBe(3)
|
||||
})
|
||||
|
||||
// CORRECT: Use flushPromises for API calls
|
||||
test('loads data from API', async () => {
|
||||
const wrapper = mount(DataLoader)
|
||||
|
||||
// Wait for all pending promises to resolve
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.data').text()).toBe('Loaded data')
|
||||
})
|
||||
```
|
||||
|
||||
## When to Use Each Method
|
||||
|
||||
### `await trigger()` / `await setValue()` - User Interactions
|
||||
```javascript
|
||||
// These methods return nextTick internally
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue('new value')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
```
|
||||
|
||||
### `await nextTick()` - Programmatic Reactive Updates
|
||||
```javascript
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
test('reflects programmatic state changes', async () => {
|
||||
const wrapper = mount(Counter)
|
||||
|
||||
// Direct state modification (when testing with exposed internals)
|
||||
wrapper.vm.count = 5
|
||||
|
||||
await nextTick() // Wait for Vue to update DOM
|
||||
|
||||
expect(wrapper.find('.count').text()).toBe('5')
|
||||
})
|
||||
```
|
||||
|
||||
### `await flushPromises()` - External Async Operations
|
||||
```javascript
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
|
||||
test('displays fetched data', async () => {
|
||||
const wrapper = mount(UserProfile, {
|
||||
props: { userId: 1 }
|
||||
})
|
||||
|
||||
// Wait for component's API call to complete
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.username').text()).toBe('John')
|
||||
})
|
||||
|
||||
// Sometimes you need multiple flushPromises for chained async operations
|
||||
test('processes data after fetch', async () => {
|
||||
const wrapper = mount(DataProcessor)
|
||||
|
||||
await flushPromises() // Wait for fetch
|
||||
await flushPromises() // Wait for processing triggered by fetch
|
||||
|
||||
expect(wrapper.find('.processed').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Common Pattern: Combining Methods
|
||||
```javascript
|
||||
test('submits form and shows success', async () => {
|
||||
const wrapper = mount(ContactForm)
|
||||
|
||||
// Fill form (awaiting each interaction)
|
||||
await wrapper.find('#name').setValue('John')
|
||||
await wrapper.find('#email').setValue('john@example.com')
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
// Wait for API submission to complete
|
||||
await flushPromises()
|
||||
|
||||
// Assert success state
|
||||
expect(wrapper.find('.success-message').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with MSW or Mock APIs
|
||||
```javascript
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { rest } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('/api/user', (req, res, ctx) => {
|
||||
return res(ctx.json({ name: 'John' }))
|
||||
})
|
||||
)
|
||||
|
||||
test('displays user data', async () => {
|
||||
const wrapper = mount(UserCard)
|
||||
|
||||
// MSW might require multiple flushPromises
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.name').text()).toBe('John')
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense)
|
||||
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
---
|
||||
title: Choose Browser-Based Runner for Style and DOM Event Testing
|
||||
impact: MEDIUM
|
||||
impactDescription: Node-based runners cannot test real CSS behavior, native DOM events, cookies, or computed styles
|
||||
type: capability
|
||||
tags: [vue3, testing, component-testing, vitest, browser, jsdom]
|
||||
---
|
||||
|
||||
# Choose Browser-Based Runner for Style and DOM Event Testing
|
||||
|
||||
**Impact: MEDIUM** - Node-based test runners (Vitest with jsdom/happy-dom) simulate the DOM but cannot test real CSS rendering, native browser events, cookies, computed styles, or cross-browser behavior. Use browser-based runners when these matter.
|
||||
|
||||
Use Vitest for most component tests (fast), but use Vitest Browser Mode when testing visual/DOM-dependent features.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use Vitest (node) for logic-focused component tests
|
||||
- [ ] Use Vitest Browser Mode for style-dependent tests
|
||||
- [ ] Use Vitest Browser Mode for native events (focus, drag, resize)
|
||||
- [ ] Use Vitest Browser Mode for cookies and computed CSS styles
|
||||
- [ ] Accept slower speed tradeoff for browser accuracy
|
||||
|
||||
## When to Use Each Approach
|
||||
|
||||
### Node-Based Runner (Vitest + happy-dom/jsdom)
|
||||
Best for:
|
||||
- Pure logic testing
|
||||
- State management
|
||||
- Event emission
|
||||
- Props/slots behavior
|
||||
- Most component interactions
|
||||
- Fast CI/CD pipelines
|
||||
|
||||
```javascript
|
||||
// vitest.config.js
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom', // or 'jsdom'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Fast but limited - fine for most tests
|
||||
test('button emits click event', async () => {
|
||||
const wrapper = mount(Button)
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
})
|
||||
```
|
||||
|
||||
### Vitest Browser Mode
|
||||
Required for:
|
||||
- CSS computed styles verification
|
||||
- CSS transitions/animations
|
||||
- Real focus/blur behavior
|
||||
- Drag and drop
|
||||
- Cookie operations
|
||||
- Viewport-dependent behavior
|
||||
- Cross-browser validation
|
||||
|
||||
## Vitest Browser Mode Setup
|
||||
|
||||
```bash
|
||||
npm install -D @vitest/browser playwright
|
||||
```
|
||||
|
||||
```javascript
|
||||
// vitest.config.js
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Button.browser.test.js
|
||||
import { render } from 'vitest-browser-vue'
|
||||
import Button from './Button.vue'
|
||||
|
||||
test('has correct hover styling', async () => {
|
||||
const { getByRole } = render(Button, { props: { label: 'Click me' } })
|
||||
|
||||
const button = getByRole('button')
|
||||
|
||||
// Check initial style
|
||||
await expect.element(button).toHaveStyle({
|
||||
backgroundColor: 'rgb(59, 130, 246)' // blue
|
||||
})
|
||||
})
|
||||
|
||||
test('maintains focus after click', async () => {
|
||||
const { getByRole } = render(Button)
|
||||
|
||||
const button = getByRole('button')
|
||||
await button.click()
|
||||
|
||||
await expect.element(button).toHaveFocus()
|
||||
})
|
||||
```
|
||||
|
||||
## Examples: What Each Runner Can/Cannot Test
|
||||
|
||||
### Styles - Browser Required
|
||||
```javascript
|
||||
// Node runner: CANNOT verify actual CSS
|
||||
test('danger button has red background', () => {
|
||||
const wrapper = mount(Button, { props: { variant: 'danger' } })
|
||||
// This only checks class exists, not actual color
|
||||
expect(wrapper.classes()).toContain('bg-red-500')
|
||||
})
|
||||
|
||||
// Vitest Browser Mode: CAN verify computed styles
|
||||
test('danger button renders red', async () => {
|
||||
const { getByRole } = render(Button, { props: { variant: 'danger' } })
|
||||
await expect.element(getByRole('button')).toHaveStyle({
|
||||
backgroundColor: 'rgb(239, 68, 68)'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Computed CSS Styles - Browser Required
|
||||
```javascript
|
||||
// Node runner: CANNOT get real computed styles
|
||||
test('button has correct padding', () => {
|
||||
const wrapper = mount(Button)
|
||||
// getComputedStyle returns empty/default values in jsdom
|
||||
const style = window.getComputedStyle(wrapper.element)
|
||||
// style.padding will be empty string, not actual computed value
|
||||
})
|
||||
|
||||
// Vitest Browser Mode: Real computed styles
|
||||
test('button has correct padding', async () => {
|
||||
const { getByRole } = render(Button)
|
||||
const button = getByRole('button')
|
||||
|
||||
await expect.element(button).toHaveStyle({
|
||||
padding: '12px 24px'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Native Events - Browser Required
|
||||
```javascript
|
||||
// Node runner: Synthetic events only
|
||||
test('handles drag and drop', async () => {
|
||||
const wrapper = mount(DraggableList)
|
||||
// trigger('dragstart') is synthetic - may not work as expected
|
||||
await wrapper.find('.item').trigger('dragstart')
|
||||
})
|
||||
|
||||
// Vitest Browser Mode: Real native events via userEvent
|
||||
import { userEvent } from '@vitest/browser/context'
|
||||
|
||||
test('reorders items on drag', async () => {
|
||||
const { getByTestId } = render(DraggableList)
|
||||
|
||||
const item = getByTestId('item-1')
|
||||
const target = getByTestId('item-3')
|
||||
|
||||
await userEvent.dragAndDrop(item, target)
|
||||
|
||||
// Assert reordering
|
||||
})
|
||||
```
|
||||
|
||||
## Recommended Testing Strategy
|
||||
|
||||
```javascript
|
||||
// vitest.config.js - Separate test configurations
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Default: Node environment for speed
|
||||
environment: 'happy-dom',
|
||||
|
||||
// Browser tests in separate directory
|
||||
include: ['src/**/*.test.{js,ts}'],
|
||||
},
|
||||
})
|
||||
|
||||
// Run browser tests separately
|
||||
// npx vitest --browser.enabled
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Fast node-based tests
|
||||
│ ├── Button.test.js
|
||||
│ └── useCounter.test.js
|
||||
├── component/ # Slower browser-based tests
|
||||
│ ├── Button.browser.test.js
|
||||
│ └── DragDrop.browser.test.js
|
||||
└── e2e/ # Full E2E tests (Playwright)
|
||||
└── user-flow.spec.ts
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Testing - Component Testing](https://vuejs.org/guide/scaling-up/testing#component-testing)
|
||||
- [Vitest Browser Mode](https://vitest.dev/guide/browser.html)
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
---
|
||||
title: Test Components Using Blackbox Approach - Focus on Behavior Not Implementation
|
||||
impact: HIGH
|
||||
impactDescription: Implementation-aware tests become brittle and break during refactoring, leading to high maintenance burden
|
||||
type: best-practice
|
||||
tags: [vue3, testing, component-testing, vitest, vue-test-utils, blackbox]
|
||||
---
|
||||
|
||||
# Test Components Using Blackbox Approach - Focus on Behavior Not Implementation
|
||||
|
||||
**Impact: HIGH** - Tests that rely on implementation details (internal state, private methods, component structure) break during refactoring even when functionality remains correct. This leads to false negatives and high test maintenance burden.
|
||||
|
||||
Follow Kent C. Dodds' testing philosophy: "The more your tests resemble how your software is used, the more confidence they can give you."
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Test what the component does, not how it does it
|
||||
- [ ] Query elements by user-visible attributes (text, role, testid)
|
||||
- [ ] Simulate user interactions (click, type) rather than calling methods directly
|
||||
- [ ] Assert on rendered output, emitted events, and visible state changes
|
||||
- [ ] Avoid accessing component internal state or private methods
|
||||
- [ ] Use data-testid attributes for elements without semantic meaning
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Counter from './Counter.vue'
|
||||
|
||||
// BAD: Testing implementation details
|
||||
test('counter increments', async () => {
|
||||
const wrapper = mount(Counter)
|
||||
|
||||
// Accessing internal state directly
|
||||
expect(wrapper.vm.count).toBe(0)
|
||||
|
||||
// Calling internal method instead of simulating user action
|
||||
wrapper.vm.increment()
|
||||
|
||||
// Checking internal state instead of visible output
|
||||
expect(wrapper.vm.count).toBe(1)
|
||||
})
|
||||
|
||||
// BAD: Testing component structure
|
||||
test('has increment button', () => {
|
||||
const wrapper = mount(Counter)
|
||||
|
||||
// Testing implementation detail - what if button becomes an anchor?
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Counter from './Counter.vue'
|
||||
|
||||
// CORRECT: Testing behavior like a user would
|
||||
test('counter displays updated value after clicking increment', async () => {
|
||||
const wrapper = mount(Counter, {
|
||||
props: { max: 10 }
|
||||
})
|
||||
|
||||
// Assert initial visible state
|
||||
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('0')
|
||||
|
||||
// Simulate user action
|
||||
await wrapper.find('[data-testid="increment-button"]').trigger('click')
|
||||
|
||||
// Assert visible result
|
||||
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('1')
|
||||
})
|
||||
|
||||
// CORRECT: Testing emitted events (public API)
|
||||
test('emits change event with new value when incremented', async () => {
|
||||
const wrapper = mount(Counter)
|
||||
|
||||
await wrapper.find('[data-testid="increment-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('change')).toHaveLength(1)
|
||||
expect(wrapper.emitted('change')[0]).toEqual([1])
|
||||
})
|
||||
```
|
||||
|
||||
## Using @testing-library/vue for Better Blackbox Tests
|
||||
|
||||
```javascript
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import Counter from './Counter.vue'
|
||||
|
||||
// Testing Library encourages accessible, user-centric queries
|
||||
test('increments counter on button click', async () => {
|
||||
render(Counter)
|
||||
|
||||
// Query by role - how screen readers see it
|
||||
const button = screen.getByRole('button', { name: /increment/i })
|
||||
const display = screen.getByText('0')
|
||||
|
||||
await fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
## What to Test vs What Not to Test
|
||||
|
||||
### DO Test (Public Interface)
|
||||
```javascript
|
||||
// Props affect rendered output
|
||||
test('shows title from props', () => {
|
||||
const wrapper = mount(Card, {
|
||||
props: { title: 'Hello World' }
|
||||
})
|
||||
expect(wrapper.text()).toContain('Hello World')
|
||||
})
|
||||
|
||||
// Slots render correctly
|
||||
test('renders slot content', () => {
|
||||
const wrapper = mount(Card, {
|
||||
slots: { default: '<p>Slot content</p>' }
|
||||
})
|
||||
expect(wrapper.text()).toContain('Slot content')
|
||||
})
|
||||
|
||||
// Emitted events
|
||||
test('emits close event when X clicked', async () => {
|
||||
const wrapper = mount(Modal)
|
||||
await wrapper.find('[data-testid="close-button"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
})
|
||||
```
|
||||
|
||||
### DON'T Test (Implementation Details)
|
||||
```javascript
|
||||
// Don't test internal computed properties
|
||||
// Don't test internal methods
|
||||
// Don't test component options/setup internals
|
||||
// Don't test that specific child components are rendered (unless critical)
|
||||
// Don't rely exclusively on snapshot tests for correctness
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
|
||||
- [Vue Test Utils - Testing Philosophy](https://test-utils.vuejs.org/guide/)
|
||||
- [Testing Library Guiding Principles](https://testing-library.com/docs/guiding-principles)
|
||||
-238
@@ -1,238 +0,0 @@
|
||||
---
|
||||
title: Test Complex Composables with Host Component Wrapper
|
||||
impact: MEDIUM
|
||||
impactDescription: Composables using lifecycle hooks or provide/inject fail when tested directly without a component context
|
||||
type: capability
|
||||
tags: [vue3, testing, composables, vitest, lifecycle-hooks, provide-inject]
|
||||
---
|
||||
|
||||
# Test Complex Composables with Host Component Wrapper
|
||||
|
||||
**Impact: MEDIUM** - Composables that use Vue lifecycle hooks (`onMounted`, `onUnmounted`) or dependency injection (`inject`) require a component context to function. Testing them directly will cause errors or incorrect behavior.
|
||||
|
||||
Simple composables using only reactivity APIs can be tested directly. Complex composables need a helper function that creates a host component context.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Identify if composable uses lifecycle hooks or inject
|
||||
- [ ] For simple composables (refs, computed only): test directly
|
||||
- [ ] For complex composables: use `withSetup` helper pattern
|
||||
- [ ] Clean up by unmounting the test app after each test
|
||||
- [ ] Use `app.provide()` to mock injected dependencies
|
||||
|
||||
**Simple Composable - Test Directly:**
|
||||
```javascript
|
||||
// composables/useCounter.js
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export function useCounter(initialValue = 0) {
|
||||
const count = ref(initialValue)
|
||||
const doubled = computed(() => count.value * 2)
|
||||
const increment = () => count.value++
|
||||
|
||||
return { count, doubled, increment }
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// useCounter.test.js
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useCounter } from './useCounter'
|
||||
|
||||
// CORRECT: Simple composable can be tested directly
|
||||
describe('useCounter', () => {
|
||||
it('initializes with default value', () => {
|
||||
const { count } = useCounter()
|
||||
expect(count.value).toBe(0)
|
||||
})
|
||||
|
||||
it('increments count', () => {
|
||||
const { count, increment } = useCounter()
|
||||
increment()
|
||||
expect(count.value).toBe(1)
|
||||
})
|
||||
|
||||
it('computes doubled value', () => {
|
||||
const { count, doubled, increment } = useCounter(5)
|
||||
expect(doubled.value).toBe(10)
|
||||
increment()
|
||||
expect(doubled.value).toBe(12)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Complex Composable - Use Host Wrapper:**
|
||||
```javascript
|
||||
// composables/useFetch.js
|
||||
import { ref, onMounted, onUnmounted, inject } from 'vue'
|
||||
|
||||
export function useFetch(url) {
|
||||
const data = ref(null)
|
||||
const error = ref(null)
|
||||
const loading = ref(true)
|
||||
let controller = null
|
||||
|
||||
// Uses inject - needs component context
|
||||
const apiClient = inject('apiClient')
|
||||
|
||||
// Uses lifecycle hooks - needs component context
|
||||
onMounted(async () => {
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const response = await apiClient.get(url, { signal: controller.signal })
|
||||
data.value = response.data
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
controller?.abort()
|
||||
})
|
||||
|
||||
return { data, error, loading }
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// test-utils.js
|
||||
import { createApp } from 'vue'
|
||||
|
||||
/**
|
||||
* Helper to test composables that need component context
|
||||
*/
|
||||
export function withSetup(composable) {
|
||||
let result
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = composable()
|
||||
// Return a render function to suppress warnings
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
|
||||
app.mount(document.createElement('div'))
|
||||
|
||||
return [result, app]
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// useFetch.test.js
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { withSetup } from './test-utils'
|
||||
import { useFetch } from './useFetch'
|
||||
|
||||
describe('useFetch', () => {
|
||||
let app
|
||||
const mockApiClient = {
|
||||
get: vi.fn()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// IMPORTANT: Clean up to trigger onUnmounted
|
||||
app?.unmount()
|
||||
})
|
||||
|
||||
it('fetches data on mount', async () => {
|
||||
mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } })
|
||||
|
||||
const [result, testApp] = withSetup(() => useFetch('/api/test'))
|
||||
app = testApp
|
||||
|
||||
// Provide mocked dependency
|
||||
app.provide('apiClient', mockApiClient)
|
||||
|
||||
// Wait for async operations
|
||||
await flushPromises()
|
||||
|
||||
expect(result.data.value).toEqual({ id: 1, name: 'Test' })
|
||||
expect(result.loading.value).toBe(false)
|
||||
expect(result.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('handles errors', async () => {
|
||||
const testError = new Error('Network error')
|
||||
mockApiClient.get.mockRejectedValue(testError)
|
||||
|
||||
const [result, testApp] = withSetup(() => useFetch('/api/test'))
|
||||
app = testApp
|
||||
app.provide('apiClient', mockApiClient)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(result.error.value).toBe(testError)
|
||||
expect(result.data.value).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Enhanced withSetup Helper with Provide Support
|
||||
```javascript
|
||||
// test-utils.js
|
||||
export function withSetup(composable, options = {}) {
|
||||
let result
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = composable()
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
|
||||
// Apply global provides before mounting
|
||||
if (options.provide) {
|
||||
Object.entries(options.provide).forEach(([key, value]) => {
|
||||
app.provide(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
app.mount(document.createElement('div'))
|
||||
|
||||
return [result, app]
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [result, app] = withSetup(() => useMyComposable(), {
|
||||
provide: {
|
||||
apiClient: mockApiClient,
|
||||
currentUser: { id: 1, name: 'Test User' }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with @vue/test-utils mount
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { useFetch } from './useFetch'
|
||||
|
||||
test('useFetch in component context', async () => {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { data, loading } = useFetch('/api/users')
|
||||
return { data, loading }
|
||||
},
|
||||
template: '<div>{{ loading ? "Loading..." : data }}</div>'
|
||||
})
|
||||
|
||||
const wrapper = mount(TestComponent, {
|
||||
global: {
|
||||
provide: {
|
||||
apiClient: mockApiClient
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Test data')
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Testing Guide - Testing Composables](https://vuejs.org/guide/scaling-up/testing#testing-composables)
|
||||
- [Vue Test Utils - Mounting Components](https://test-utils.vuejs.org/guide/)
|
||||
-242
@@ -1,242 +0,0 @@
|
||||
---
|
||||
title: Use Playwright for E2E Testing - Cross-Browser Support and Better DX
|
||||
impact: MEDIUM
|
||||
impactDescription: Cypress has browser limitations and some features require paid subscriptions
|
||||
type: best-practice
|
||||
tags: [vue3, testing, e2e, playwright, cypress, end-to-end]
|
||||
---
|
||||
|
||||
# Use Playwright for E2E Testing - Cross-Browser Support and Better DX
|
||||
|
||||
**Impact: MEDIUM** - Playwright offers superior cross-browser testing (Chromium, WebKit, Firefox), excellent debugging tools, and is fully open source. Cypress has limitations with WebKit support and requires paid subscriptions for some features.
|
||||
|
||||
Use Playwright for new E2E testing setups. Consider Cypress if team already has expertise or for its visual debugging UI.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Install Playwright with browsers for your target platforms
|
||||
- [ ] Configure for Vue dev server integration
|
||||
- [ ] Set up projects for different browsers
|
||||
- [ ] Use locator strategies that match component test patterns
|
||||
- [ ] Configure CI for parallel test execution
|
||||
- [ ] Use trace and screenshot features for debugging
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
# Install Playwright
|
||||
npm init playwright@latest
|
||||
|
||||
# This will create:
|
||||
# - playwright.config.ts
|
||||
# - tests/ directory
|
||||
# - tests-examples/ directory
|
||||
```
|
||||
|
||||
**playwright.config.ts:**
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
// Base URL for navigation
|
||||
baseURL: 'http://localhost:5173',
|
||||
// Capture trace on first retry
|
||||
trace: 'on-first-retry',
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// Mobile viewports
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
],
|
||||
|
||||
// Run local dev server before tests
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## E2E Test Example
|
||||
|
||||
```typescript
|
||||
// e2e/user-flow.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('User Authentication', () => {
|
||||
test('user can log in and see dashboard', async ({ page }) => {
|
||||
// Navigate to login
|
||||
await page.goto('/login')
|
||||
|
||||
// Fill login form
|
||||
await page.getByLabel('Email').fill('user@example.com')
|
||||
await page.getByLabel('Password').fill('password123')
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
// Verify redirect to dashboard
|
||||
await expect(page).toHaveURL('/dashboard')
|
||||
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows error for invalid credentials', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
|
||||
await page.getByLabel('Email').fill('wrong@example.com')
|
||||
await page.getByLabel('Password').fill('wrongpassword')
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('Invalid credentials')
|
||||
await expect(page).toHaveURL('/login')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Playwright vs Cypress Comparison
|
||||
|
||||
| Feature | Playwright | Cypress |
|
||||
|---------|------------|---------|
|
||||
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, Electron (WebKit experimental) |
|
||||
| Cross-browser | Full support | Limited |
|
||||
| Parallelization | Built-in | Requires Cypress Cloud |
|
||||
| Open source | Fully | Core only |
|
||||
| Mobile testing | Device emulation | Limited |
|
||||
| Debugging | Inspector, trace viewer | Time-travel UI |
|
||||
| API testing | Built-in | Plugin required |
|
||||
| Iframes | Full support | Limited |
|
||||
|
||||
## Testing Vue Components with Data-Testid
|
||||
|
||||
```typescript
|
||||
// e2e/product-list.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('user can add product to cart', async ({ page }) => {
|
||||
await page.goto('/products')
|
||||
|
||||
// Use data-testid for reliable selectors
|
||||
await page.getByTestId('product-card').first().click()
|
||||
|
||||
// Verify product detail page
|
||||
await expect(page.getByTestId('product-title')).toBeVisible()
|
||||
|
||||
// Add to cart
|
||||
await page.getByTestId('add-to-cart-button').click()
|
||||
|
||||
// Verify cart updated
|
||||
await expect(page.getByTestId('cart-count')).toHaveText('1')
|
||||
})
|
||||
```
|
||||
|
||||
## Page Object Pattern for Vue Apps
|
||||
|
||||
```typescript
|
||||
// e2e/pages/LoginPage.ts
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page
|
||||
readonly emailInput: Locator
|
||||
readonly passwordInput: Locator
|
||||
readonly submitButton: Locator
|
||||
readonly errorMessage: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.emailInput = page.getByLabel('Email')
|
||||
this.passwordInput = page.getByLabel('Password')
|
||||
this.submitButton = page.getByRole('button', { name: 'Sign In' })
|
||||
this.errorMessage = page.getByRole('alert')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login')
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
await this.emailInput.fill(email)
|
||||
await this.passwordInput.fill(password)
|
||||
await this.submitButton.click()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// e2e/auth.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
|
||||
test('successful login', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await loginPage.login('user@example.com', 'password123')
|
||||
|
||||
await expect(page).toHaveURL('/dashboard')
|
||||
})
|
||||
```
|
||||
|
||||
## Visual Regression Testing
|
||||
|
||||
```typescript
|
||||
test('homepage visual regression', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Full page screenshot comparison
|
||||
await expect(page).toHaveScreenshot('homepage.png')
|
||||
|
||||
// Element-specific screenshot
|
||||
await expect(page.getByTestId('hero-section')).toHaveScreenshot('hero.png')
|
||||
})
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npx playwright test
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run specific file
|
||||
npx playwright test e2e/auth.spec.ts
|
||||
|
||||
# Run in specific browser
|
||||
npx playwright test --project=chromium
|
||||
|
||||
# Debug mode
|
||||
npx playwright test --debug
|
||||
|
||||
# Generate test from actions
|
||||
npx playwright codegen localhost:5173
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
- [Vue.js E2E Testing Recommendations](https://vuejs.org/guide/scaling-up/testing#e2e-testing)
|
||||
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
@@ -1,197 +0,0 @@
|
||||
---
|
||||
title: Avoid Snapshot-Only Tests - They Don't Prove Correctness
|
||||
impact: MEDIUM
|
||||
impactDescription: Snapshot tests verify structure but not functionality, leading to false confidence and brittle tests
|
||||
type: best-practice
|
||||
tags: [vue3, testing, snapshot, vitest, vue-test-utils, anti-pattern]
|
||||
---
|
||||
|
||||
# Avoid Snapshot-Only Tests - They Don't Prove Correctness
|
||||
|
||||
**Impact: MEDIUM** - Snapshot tests only verify that HTML structure hasn't changed - they don't verify that the component works correctly. Relying exclusively on snapshots leads to false confidence and tests that break on any refactoring, even when functionality is preserved.
|
||||
|
||||
Use snapshots sparingly for regression detection. Prefer behavioral assertions that test what the component does.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Don't use snapshots as the only assertion for component behavior
|
||||
- [ ] Use snapshots for regression detection on stable UI components
|
||||
- [ ] Always pair snapshots with behavioral assertions
|
||||
- [ ] Keep snapshots small and focused (avoid full component snapshots)
|
||||
- [ ] Review snapshot diffs carefully - don't blindly update
|
||||
- [ ] Consider inline snapshots for small, critical structures
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
// BAD: Snapshot-only test proves nothing about functionality
|
||||
test('UserCard renders correctly', () => {
|
||||
const wrapper = mount(UserCard, {
|
||||
props: { user: { name: 'John', email: 'john@example.com' } }
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
// This test passes even if:
|
||||
// - The email isn't clickable
|
||||
// - The avatar doesn't load
|
||||
// - User actions are completely broken
|
||||
// - Accessibility is broken
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
// CORRECT: Test actual behavior
|
||||
test('UserCard displays user information', () => {
|
||||
const wrapper = mount(UserCard, {
|
||||
props: { user: { name: 'John', email: 'john@example.com' } }
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="user-name"]').text()).toBe('John')
|
||||
expect(wrapper.find('[data-testid="user-email"]').text()).toBe('john@example.com')
|
||||
})
|
||||
|
||||
test('UserCard email link is clickable', async () => {
|
||||
const wrapper = mount(UserCard, {
|
||||
props: { user: { name: 'John', email: 'john@example.com' } }
|
||||
})
|
||||
|
||||
const emailLink = wrapper.find('a[href^="mailto:"]')
|
||||
expect(emailLink.exists()).toBe(true)
|
||||
expect(emailLink.attributes('href')).toBe('mailto:john@example.com')
|
||||
})
|
||||
|
||||
test('UserCard emits select event when clicked', async () => {
|
||||
const wrapper = mount(UserCard, {
|
||||
props: { user: { id: 1, name: 'John' } }
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toBeTruthy()
|
||||
expect(wrapper.emitted('select')[0]).toEqual([{ id: 1, name: 'John' }])
|
||||
})
|
||||
```
|
||||
|
||||
## When Snapshots ARE Useful
|
||||
|
||||
### Regression Detection for Stable Components
|
||||
```javascript
|
||||
// ACCEPTABLE: Snapshot as additional check, not the only check
|
||||
test('ErrorBoundary renders error message', () => {
|
||||
const wrapper = mount(ErrorBoundary, {
|
||||
props: { error: new Error('Something went wrong') }
|
||||
})
|
||||
|
||||
// Primary assertions - verify behavior
|
||||
expect(wrapper.find('.error-title').text()).toBe('Error')
|
||||
expect(wrapper.find('.error-message').text()).toContain('Something went wrong')
|
||||
|
||||
// Secondary snapshot - catches unexpected structural changes
|
||||
expect(wrapper.find('.error-container').html()).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
### Inline Snapshots for Small Structures
|
||||
```javascript
|
||||
// ACCEPTABLE: Inline snapshot for small, critical structure
|
||||
test('generates correct list markup', () => {
|
||||
const wrapper = mount(ListItem, { props: { item: 'Test' } })
|
||||
|
||||
expect(wrapper.html()).toMatchInlineSnapshot(`
|
||||
"<li class="list-item">Test</li>"
|
||||
`)
|
||||
})
|
||||
```
|
||||
|
||||
### Complex SVG or Icon Output
|
||||
```javascript
|
||||
// ACCEPTABLE: Snapshot for complex generated content
|
||||
test('renders correct chart SVG', () => {
|
||||
const wrapper = mount(PieChart, {
|
||||
props: { data: [30, 40, 30] }
|
||||
})
|
||||
|
||||
// Verify key behavior
|
||||
expect(wrapper.findAll('path').length).toBe(3)
|
||||
|
||||
// Snapshot for full SVG structure
|
||||
expect(wrapper.find('svg').html()).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
## Better Alternatives to Snapshots
|
||||
|
||||
### Test Specific Elements
|
||||
```javascript
|
||||
// Instead of snapshotting entire component
|
||||
test('renders product with all required fields', () => {
|
||||
const wrapper = mount(ProductCard, {
|
||||
props: { product: { name: 'Widget', price: 9.99, inStock: true } }
|
||||
})
|
||||
|
||||
expect(wrapper.find('.product-name').text()).toBe('Widget')
|
||||
expect(wrapper.find('.product-price').text()).toContain('9.99')
|
||||
expect(wrapper.find('.in-stock-badge').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
### Test CSS Classes for Styling
|
||||
```javascript
|
||||
test('applies danger styling for errors', () => {
|
||||
const wrapper = mount(Alert, {
|
||||
props: { type: 'error', message: 'Failed!' }
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).toContain('alert-danger')
|
||||
expect(wrapper.find('.alert-icon').classes()).toContain('icon-error')
|
||||
})
|
||||
```
|
||||
|
||||
### Use Testing Library Queries
|
||||
```javascript
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
test('form has accessible labels', () => {
|
||||
render(LoginForm)
|
||||
|
||||
// Testing Library queries verify accessibility
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
## Snapshot Anti-Patterns
|
||||
|
||||
```javascript
|
||||
// ANTI-PATTERN: Giant component snapshot
|
||||
test('page renders', () => {
|
||||
const wrapper = mount(EntirePageComponent)
|
||||
expect(wrapper.html()).toMatchSnapshot() // 500+ lines of HTML
|
||||
})
|
||||
|
||||
// ANTI-PATTERN: Snapshot with dynamic content
|
||||
test('shows current date', () => {
|
||||
const wrapper = mount(DateDisplay)
|
||||
expect(wrapper.html()).toMatchSnapshot() // Fails every day!
|
||||
})
|
||||
|
||||
// ANTI-PATTERN: Snapshot after every test
|
||||
test('button works', async () => {
|
||||
const wrapper = mount(Counter)
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.html()).toMatchSnapshot() // Redundant
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Testing Guide - What Not to Test](https://vuejs.org/guide/scaling-up/testing)
|
||||
- [Effective Snapshot Testing](https://kentcdodds.com/blog/effective-snapshot-testing)
|
||||
- [Vitest Snapshot Testing](https://vitest.dev/guide/snapshot.html)
|
||||
@@ -1,228 +0,0 @@
|
||||
---
|
||||
title: Configure Pinia Testing with createTestingPinia and setActivePinia
|
||||
impact: HIGH
|
||||
impactDescription: Missing Pinia configuration causes 'injection Symbol(pinia) not found' errors and failing tests
|
||||
type: gotcha
|
||||
tags: [vue3, testing, pinia, vitest, store, mocking, createTestingPinia]
|
||||
---
|
||||
|
||||
# Configure Pinia Testing with createTestingPinia and setActivePinia
|
||||
|
||||
**Impact: HIGH** - Testing components or composables that use Pinia stores without proper configuration results in "[Vue warn]: injection Symbol(pinia) not found" errors. Tests will fail or behave unexpectedly.
|
||||
|
||||
Use `@pinia/testing` package with `createTestingPinia` for component tests and `setActivePinia(createPinia())` for unit testing stores directly.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Install `@pinia/testing` as a dev dependency
|
||||
- [ ] Use `createTestingPinia` in component tests with `global.plugins`
|
||||
- [ ] Use `setActivePinia(createPinia())` in `beforeEach` for store unit tests
|
||||
- [ ] Configure `createSpy: vi.fn` when NOT using `globals: true` in Vitest
|
||||
- [ ] Initialize store inside each test to get fresh state
|
||||
- [ ] Use `stubActions: false` when you need real action execution
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UserProfile from './UserProfile.vue'
|
||||
|
||||
// BAD: Missing Pinia - causes injection error
|
||||
test('displays user name', () => {
|
||||
const wrapper = mount(UserProfile) // ERROR: injection "Symbol(pinia)" not found
|
||||
expect(wrapper.text()).toContain('John')
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// BAD: No active Pinia instance
|
||||
test('user store actions', () => {
|
||||
const store = useUserStore() // ERROR: no active Pinia
|
||||
store.login('john', 'password')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct - Component Testing:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { vi } from 'vitest'
|
||||
import UserProfile from './UserProfile.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// CORRECT: Provide testing pinia with stubbed actions
|
||||
test('displays user name', () => {
|
||||
const wrapper = mount(UserProfile, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn, // Required if not using globals: true
|
||||
initialState: {
|
||||
user: { name: 'John', email: 'john@example.com' }
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('John')
|
||||
})
|
||||
|
||||
// CORRECT: Test with stubbed actions (default behavior)
|
||||
test('calls logout action', async () => {
|
||||
const wrapper = mount(UserProfile, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })]
|
||||
}
|
||||
})
|
||||
|
||||
// Get store AFTER mounting with createTestingPinia
|
||||
const store = useUserStore()
|
||||
|
||||
await wrapper.find('[data-testid="logout"]').trigger('click')
|
||||
|
||||
// Actions are stubbed and wrapped in spies
|
||||
expect(store.logout).toHaveBeenCalled()
|
||||
})
|
||||
```
|
||||
|
||||
**Correct - Store Unit Testing:**
|
||||
```javascript
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
describe('User Store', () => {
|
||||
beforeEach(() => {
|
||||
// Create fresh Pinia instance for each test
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('initializes with empty user', () => {
|
||||
const store = useUserStore()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('updates user on login', async () => {
|
||||
const store = useUserStore()
|
||||
|
||||
// Real action executes - not stubbed
|
||||
await store.login('john', 'password')
|
||||
|
||||
expect(store.user).toEqual({ name: 'John' })
|
||||
expect(store.isLoggedIn).toBe(true)
|
||||
})
|
||||
|
||||
it('clears user on logout', () => {
|
||||
const store = useUserStore()
|
||||
store.user = { name: 'John' } // Set initial state
|
||||
|
||||
store.logout()
|
||||
|
||||
expect(store.user).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with Real Actions vs Stubbed Actions
|
||||
|
||||
```javascript
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
// Stubbed actions (default) - for isolation
|
||||
const wrapper = mount(Component, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
// stubActions: true (default) - actions are mocked
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Real actions - for integration testing
|
||||
const wrapper = mount(Component, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false // Actions execute normally
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Mocking Specific Action Implementations
|
||||
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { vi } from 'vitest'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
|
||||
test('handles checkout failure', async () => {
|
||||
const wrapper = mount(Checkout, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })]
|
||||
}
|
||||
})
|
||||
|
||||
const cartStore = useCartStore()
|
||||
|
||||
// Mock specific action behavior
|
||||
cartStore.checkout.mockRejectedValue(new Error('Payment failed'))
|
||||
|
||||
await wrapper.find('[data-testid="checkout"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error').text()).toContain('Payment failed')
|
||||
})
|
||||
```
|
||||
|
||||
## Spying on Actions with vi.spyOn
|
||||
|
||||
```javascript
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { vi } from 'vitest'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
test('tracks action calls', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useUserStore()
|
||||
|
||||
const loginSpy = vi.spyOn(store, 'login')
|
||||
loginSpy.mockResolvedValue({ success: true })
|
||||
|
||||
await store.login('john', 'password')
|
||||
|
||||
expect(loginSpy).toHaveBeenCalledWith('john', 'password')
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Store $subscribe
|
||||
|
||||
```javascript
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
test('subscription triggers on state change', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useUserStore()
|
||||
|
||||
const callback = vi.fn()
|
||||
store.$subscribe(callback)
|
||||
|
||||
store.user = { name: 'John' }
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Pinia Testing Guide](https://pinia.vuejs.org/cookbook/testing.html)
|
||||
- [@pinia/testing Package](https://www.npmjs.com/package/@pinia/testing)
|
||||
- [Vue Test Utils - Plugins](https://test-utils.vuejs.org/guide/advanced/plugins.html)
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
---
|
||||
title: Wrap Async Setup Components in Suspense for Testing
|
||||
impact: HIGH
|
||||
impactDescription: Components with async setup() fail to render in tests without Suspense wrapper, causing cryptic errors
|
||||
type: gotcha
|
||||
tags: [vue3, testing, suspense, async-setup, vue-test-utils, vitest]
|
||||
---
|
||||
|
||||
# Wrap Async Setup Components in Suspense for Testing
|
||||
|
||||
**Impact: HIGH** - Components using `async setup()` require a `<Suspense>` wrapper to function correctly. Testing them without Suspense causes the component to never render, leading to test failures and confusing errors.
|
||||
|
||||
Create a test wrapper component with Suspense or use a `mountSuspense` helper function for testing async components.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Identify components with async setup (uses `await` in `<script setup>` or `async setup()`)
|
||||
- [ ] Create a wrapper component with `<Suspense>` for testing
|
||||
- [ ] Use `flushPromises()` after mounting to wait for async resolution
|
||||
- [ ] Access the actual component via `findComponent()` for assertions
|
||||
- [ ] Consider using `@testing-library/vue` with caution (has Suspense issues)
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AsyncUserProfile from './AsyncUserProfile.vue'
|
||||
|
||||
// BAD: Async component without Suspense wrapper
|
||||
test('displays user data', async () => {
|
||||
// This won't render - Vue expects Suspense wrapper for async setup
|
||||
const wrapper = mount(AsyncUserProfile, {
|
||||
props: { userId: 1 }
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// This fails - component never rendered
|
||||
expect(wrapper.find('.username').text()).toBe('John')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct - Manual Wrapper Component:**
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, Suspense } from 'vue'
|
||||
import AsyncUserProfile from './AsyncUserProfile.vue'
|
||||
|
||||
test('displays user data', async () => {
|
||||
// Create wrapper component with Suspense
|
||||
const TestWrapper = defineComponent({
|
||||
components: { AsyncUserProfile },
|
||||
template: `
|
||||
<Suspense>
|
||||
<AsyncUserProfile :user-id="1" />
|
||||
<template #fallback>Loading...</template>
|
||||
</Suspense>
|
||||
`
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper)
|
||||
|
||||
// Initially shows fallback
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Wait for async setup to complete
|
||||
await flushPromises()
|
||||
|
||||
// Find the actual component for detailed assertions
|
||||
const profile = wrapper.findComponent(AsyncUserProfile)
|
||||
expect(profile.find('.username').text()).toBe('John')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct - Reusable Helper Function:**
|
||||
```javascript
|
||||
// test-utils.js
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, Suspense, h } from 'vue'
|
||||
|
||||
export async function mountSuspense(component, options = {}) {
|
||||
const { props, slots, ...mountOptions } = options
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
render() {
|
||||
return h(
|
||||
Suspense,
|
||||
null,
|
||||
{
|
||||
default: () => h(component, props, slots),
|
||||
fallback: () => h('div', 'Loading...')
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
mountOptions
|
||||
)
|
||||
|
||||
// Wait for async component to resolve
|
||||
await flushPromises()
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
// Provide easy access to the actual component
|
||||
component: wrapper.findComponent(component)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// AsyncUserProfile.test.js
|
||||
import { mountSuspense } from './test-utils'
|
||||
import AsyncUserProfile from './AsyncUserProfile.vue'
|
||||
|
||||
test('displays user data', async () => {
|
||||
const { component } = await mountSuspense(AsyncUserProfile, {
|
||||
props: { userId: 1 },
|
||||
global: {
|
||||
stubs: {
|
||||
// Stub any child components if needed
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(component.find('.username').text()).toBe('John')
|
||||
})
|
||||
|
||||
test('handles errors gracefully', async () => {
|
||||
const { component } = await mountSuspense(AsyncUserProfile, {
|
||||
props: { userId: 'invalid' }
|
||||
})
|
||||
|
||||
expect(component.find('.error').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with onErrorCaptured
|
||||
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, Suspense, h, ref, onErrorCaptured } from 'vue'
|
||||
import AsyncComponent from './AsyncComponent.vue'
|
||||
|
||||
test('catches async errors', async () => {
|
||||
const capturedError = ref(null)
|
||||
|
||||
const TestWrapper = defineComponent({
|
||||
setup() {
|
||||
onErrorCaptured((error) => {
|
||||
capturedError.value = error
|
||||
return true // Prevent error propagation
|
||||
})
|
||||
return { capturedError }
|
||||
},
|
||||
render() {
|
||||
return h(Suspense, null, {
|
||||
default: () => h(AsyncComponent, { shouldFail: true }),
|
||||
fallback: () => h('div', 'Loading...')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper)
|
||||
await flushPromises()
|
||||
|
||||
expect(capturedError.value).toBeTruthy()
|
||||
expect(capturedError.value.message).toContain('Failed to load')
|
||||
})
|
||||
```
|
||||
|
||||
## Using with Nuxt's mountSuspended
|
||||
|
||||
```javascript
|
||||
// If using Nuxt, use the built-in mountSuspended helper
|
||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||
import AsyncPage from './AsyncPage.vue'
|
||||
|
||||
test('renders async page', async () => {
|
||||
const wrapper = await mountSuspended(AsyncPage, {
|
||||
props: { id: 1 }
|
||||
})
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Page Title')
|
||||
})
|
||||
```
|
||||
|
||||
## Important Caveats
|
||||
|
||||
### @testing-library/vue Limitation
|
||||
```javascript
|
||||
// CAUTION: @testing-library/vue has issues with Suspense
|
||||
// Use @vue/test-utils for async components instead
|
||||
|
||||
// If you must use Testing Library, create manual wrapper:
|
||||
import { render, waitFor } from '@testing-library/vue'
|
||||
|
||||
test('async component with testing library', async () => {
|
||||
const TestWrapper = {
|
||||
template: `
|
||||
<Suspense>
|
||||
<AsyncComponent />
|
||||
</Suspense>
|
||||
`,
|
||||
components: { AsyncComponent }
|
||||
}
|
||||
|
||||
const { getByText } = render(TestWrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Loaded content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Component Instance
|
||||
```javascript
|
||||
test('access vm on async component', async () => {
|
||||
const { wrapper, component } = await mountSuspense(AsyncComponent)
|
||||
|
||||
// The wrapper.vm is the Suspense wrapper - not useful
|
||||
// Use component.vm for the actual async component
|
||||
expect(component.vm.someData).toBe('value')
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue Test Utils - Async Suspense](https://test-utils.vuejs.org/guide/advanced/async-suspense)
|
||||
- [Vue.js Suspense Documentation](https://vuejs.org/guide/built-ins/suspense.html)
|
||||
- [Testing Library Vue Suspense Issue](https://github.com/testing-library/vue-testing-library/issues/230)
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
---
|
||||
title: Use Vitest for Vue 3 Testing - Recommended by Vue Team
|
||||
impact: MEDIUM
|
||||
impactDescription: Using Jest or other runners with Vite projects requires complex configuration and causes slower test runs
|
||||
type: best-practice
|
||||
tags: [vue3, testing, vitest, vite, configuration, setup]
|
||||
---
|
||||
|
||||
# Use Vitest for Vue 3 Testing - Recommended by Vue Team
|
||||
|
||||
**Impact: MEDIUM** - Vitest is created and maintained by Vue/Vite team members and shares the same configuration and transform pipeline as Vite. Using Jest or other test runners with Vite-based projects requires additional configuration and can result in slower test execution and compatibility issues.
|
||||
|
||||
Use Vitest for new Vue 3 projects. Only consider Jest if migrating an existing test suite.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Install Vitest and related packages for Vue testing
|
||||
- [ ] Configure vitest in vite.config.js or vitest.config.js
|
||||
- [ ] Set up proper test environment (happy-dom or jsdom)
|
||||
- [ ] Add test scripts to package.json
|
||||
- [ ] Configure globals if desired for cleaner test syntax
|
||||
- [ ] Use @vue/test-utils for component mounting
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
# Install required packages
|
||||
npm install -D vitest @vue/test-utils happy-dom
|
||||
# or with jsdom
|
||||
npm install -D vitest @vue/test-utils jsdom
|
||||
```
|
||||
|
||||
**vite.config.js:**
|
||||
```javascript
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
// Enable global test APIs (describe, it, expect)
|
||||
globals: true,
|
||||
// Use happy-dom for faster tests (or 'jsdom' for better compatibility)
|
||||
environment: 'happy-dom',
|
||||
// Optional: Setup files for global configuration
|
||||
setupFiles: ['./src/test/setup.js']
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**package.json:**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**tsconfig.json (if using TypeScript):**
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test File Example
|
||||
|
||||
```javascript
|
||||
// src/components/Counter.test.js
|
||||
import { describe, it, expect, beforeEach } from 'vitest' // optional with globals: true
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Counter from './Counter.vue'
|
||||
|
||||
describe('Counter', () => {
|
||||
let wrapper
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mount(Counter)
|
||||
})
|
||||
|
||||
it('renders initial count', () => {
|
||||
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
|
||||
})
|
||||
|
||||
it('increments when button clicked', async () => {
|
||||
await wrapper.find('[data-testid="increment"]').trigger('click')
|
||||
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Vitest vs Jest Comparison
|
||||
|
||||
| Feature | Vitest | Jest |
|
||||
|---------|--------|------|
|
||||
| Vite Integration | Native | Requires config |
|
||||
| Speed | Very fast (ESM native) | Slower with Vite |
|
||||
| Watch Mode | Excellent | Good |
|
||||
| Vue SFC Support | Works with Vite | Needs vue-jest |
|
||||
| Config Sharing | Same as vite.config | Separate |
|
||||
| API | Jest-compatible | Standard |
|
||||
|
||||
## Using with Testing Library
|
||||
|
||||
```bash
|
||||
npm install -D @testing-library/vue @testing-library/jest-dom
|
||||
```
|
||||
|
||||
```javascript
|
||||
// src/test/setup.js
|
||||
import { expect } from 'vitest'
|
||||
import * as matchers from '@testing-library/jest-dom/matchers'
|
||||
|
||||
expect.extend(matchers)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Component.test.js
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
test('displays user name', () => {
|
||||
render(UserCard, {
|
||||
props: { name: 'John Doe' }
|
||||
})
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
```javascript
|
||||
// vitest.config.js (separate file if preferred)
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
exclude: ['node_modules', 'dist', 'e2e'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['node_modules', 'test']
|
||||
},
|
||||
// Helpful for debugging
|
||||
reporters: ['verbose'],
|
||||
// Run tests in sequence in CI
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: process.env.CI === 'true'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Mocking Modules
|
||||
```javascript
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api/users', () => ({
|
||||
fetchUser: vi.fn().mockResolvedValue({ name: 'John' })
|
||||
}))
|
||||
```
|
||||
|
||||
### Testing with Fake Timers
|
||||
```javascript
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('debounced search', async () => {
|
||||
const wrapper = mount(SearchBox)
|
||||
await wrapper.find('input').setValue('vue')
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
|
||||
- [Vue Test Utils](https://test-utils.vuejs.org/)
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "code-review-graph update --skip-flows",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "code-review-graph status",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: Debug Issue
|
||||
description: Systematically debug issues using graph-powered code navigation
|
||||
---
|
||||
|
||||
## Debug Issue
|
||||
|
||||
Use the knowledge graph to systematically trace and debug issues.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `semantic_search_nodes` to find code related to the issue.
|
||||
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
|
||||
3. Use `get_flow` to see full execution paths through suspected areas.
|
||||
4. Run `detect_changes` to check if recent changes caused the issue.
|
||||
5. Use `get_impact_radius` on suspected files to see what else is affected.
|
||||
|
||||
### Tips
|
||||
|
||||
- Check both callers and callees to understand the full context.
|
||||
- Look at affected flows to find the entry point that triggers the bug.
|
||||
- Recent changes are the most common source of new issues.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: Explore Codebase
|
||||
description: Navigate and understand codebase structure using the knowledge graph
|
||||
---
|
||||
|
||||
## Explore Codebase
|
||||
|
||||
Use the code-review-graph MCP tools to explore and understand the codebase.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `list_graph_stats` to see overall codebase metrics.
|
||||
2. Run `get_architecture_overview` for high-level community structure.
|
||||
3. Use `list_communities` to find major modules, then `get_community` for details.
|
||||
4. Use `semantic_search_nodes` to find specific functions or classes.
|
||||
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
|
||||
6. Use `list_flows` and `get_flow` to understand execution paths.
|
||||
|
||||
### Tips
|
||||
|
||||
- Start broad (stats, architecture) then narrow down to specific areas.
|
||||
- Use `children_of` on a file to see all its functions and classes.
|
||||
- Use `find_large_functions` to identify complex code.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/playwright
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/playwright-generate-test
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: Refactor Safely
|
||||
description: Plan and execute safe refactoring using dependency analysis
|
||||
---
|
||||
|
||||
## Refactor Safely
|
||||
|
||||
Use the knowledge graph to plan and execute refactoring with confidence.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
|
||||
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
|
||||
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
|
||||
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
|
||||
5. After changes, run `detect_changes` to verify the refactoring impact.
|
||||
|
||||
### Safety Checks
|
||||
|
||||
- Always preview before applying (rename mode gives you an edit list).
|
||||
- Check `get_impact_radius` before major refactors.
|
||||
- Use `get_affected_flows` to ensure no critical paths are broken.
|
||||
- Run `find_large_functions` to identify decomposition targets.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: Review Changes
|
||||
description: Perform a structured code review using change detection and impact
|
||||
---
|
||||
|
||||
## Review Changes
|
||||
|
||||
Perform a thorough, risk-aware code review using the knowledge graph.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `detect_changes` to get risk-scored change analysis.
|
||||
2. Run `get_affected_flows` to find impacted execution paths.
|
||||
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
|
||||
4. Run `get_impact_radius` to understand the blast radius.
|
||||
5. For any untested changes, suggest specific test cases.
|
||||
|
||||
### Output Format
|
||||
|
||||
Provide findings grouped by risk level (high/medium/low) with:
|
||||
- What changed and why it matters
|
||||
- Test coverage status
|
||||
- Suggested improvements
|
||||
- Overall merge recommendation
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-router-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-testing-best-practices
|
||||
@@ -1,7 +0,0 @@
|
||||
[mcp_servers.chrome-devtools]
|
||||
command = "npx"
|
||||
args = ["chrome-devtools-mcp@latest", "--browserUrl", "http://localhost:9222"]
|
||||
|
||||
[mcp_servers.code-review-graph]
|
||||
command = "uvx"
|
||||
args = ["code-review-graph", "serve"]
|
||||
@@ -0,0 +1,27 @@
|
||||
# vite / vite dev:預設 mode = development
|
||||
# vite build:預設 mode = production
|
||||
# vite --mode staging:改成 staging
|
||||
# vite build --mode development:build 但用 development mode
|
||||
|
||||
# 覆蓋優先從低至高
|
||||
# .env
|
||||
# .env.local
|
||||
# .env.[mode]
|
||||
# .env.[mode].local
|
||||
|
||||
|
||||
|
||||
# Vite dev proxy 目標後端 URL。
|
||||
VITE_PROXY_TARGET=http://192.168.89.54:9002
|
||||
|
||||
# Vite API base URL。
|
||||
# 使用 Vite dev proxy 時,建議維持相對路徑。
|
||||
VITE_API_BASE_URL=/service/api
|
||||
|
||||
# 登入示範開關。只有專案明確支援略過登入時才設為 true。
|
||||
VITE_SKIP_LOGIN=false
|
||||
|
||||
# 本機開發示範帳號。
|
||||
# 有後端或 demo 帳號時,複製到 .env.development.local 後填入。
|
||||
VITE_DEV_DEFAULT_USER_ID=
|
||||
VITE_DEV_DEFAULT_PASSWORD=
|
||||
+9
-2
@@ -28,6 +28,7 @@ output/playwright/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
@@ -35,5 +36,11 @@ output/playwright/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
.codex
|
||||
.agents
|
||||
.claude
|
||||
.ruler
|
||||
.playwright
|
||||
opencode.json
|
||||
.antigravitycli
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ registry=https://registry.npmjs.org/
|
||||
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
|
||||
# save-exact=true
|
||||
|
||||
# 安全防禦:禁止安裝發布未滿 7 天的套件 (預防供應鏈攻擊)
|
||||
# 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
|
||||
# npm v11.10+
|
||||
min-release-age=7
|
||||
min-release-age=4
|
||||
# pnpm
|
||||
minimum-release-age=10080
|
||||
minimum-release-age=5760
|
||||
|
||||
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
|
||||
# engine-strict=true
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio",
|
||||
"env": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"browser": {
|
||||
"browserName": "chromium",
|
||||
"launchOptions": {
|
||||
"channel": "chromium"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# Project Rules
|
||||
|
||||
## General
|
||||
- Follow the existing code style and patterns.
|
||||
@@ -1,11 +0,0 @@
|
||||
# For a complete example, see: https://okigu.com/ruler#complete-example
|
||||
|
||||
# List of agents to configure
|
||||
default_agents = ["copilot", "claude", "trae"]
|
||||
|
||||
[mcp_servers.vuetify]
|
||||
url = "https://mcp.vuetifyjs.com/mcp"
|
||||
|
||||
# https://github.com/vuetifyjs/mcp#authentication
|
||||
# [mcp_servers.vuetify.headers]
|
||||
# Authorization = "Bearer <YOUR_API_KEY>"
|
||||
@@ -4,16 +4,38 @@
|
||||
- Follow the existing code style and patterns.
|
||||
- Use pnpm for running project commands.
|
||||
- Keep code in TypeScript unless migration is required.
|
||||
- When refactoring or creating new components, review `docs/frontend-layering.md` first and follow its layering and responsibility guidelines.
|
||||
- For bulk verification failures such as `pnpm run lint`, collect the full output first, then split the work into non-overlapping batches by file or concern and assign those batches to subagents in parallel. Keep the integration and final verification pass in the main agent.
|
||||
- Before modifying or adding files in a `src/` subdirectory, read the corresponding `src/**/GUIDE.md` to understand the layer's constraints and conventions. Use `docs/llm-development-guide.md` as the index to find which GUIDE applies.
|
||||
- When the change introduces a new pattern, directory, or convention that affects layer boundaries, create or update the relevant `src/**/GUIDE.md` and ensure `docs/llm-development-guide.md` indexes it.
|
||||
- When refactoring or creating new components, review `docs/architecture-strategy.md` first and follow its layering and responsibility guidelines.
|
||||
- For UI debugging that spans implementation, state flow, and live browser behavior, use subagents deliberately: one for component contracts and event flow, one for data sources / routing / integration, and one for live browser verification. Do not edit files until the evidence from those threads converges on a root cause.
|
||||
- Treat subagent output as scoped evidence, not as a final answer. Use subagents to narrow the search space, confirm or eliminate hypotheses, and reduce local context load before making edits.
|
||||
- When a problem is directly tied to Vuetify component behavior, props, slots, accessibility output, or generated DOM, consult Vuetify MCP and official Vuetify API documentation before changing the implementation. Prefer supported Vuetify props, slots, and documented extension points over custom replacements unless the documented API is insufficient.
|
||||
|
||||
## Naming Generalization Rule
|
||||
- This project is a **template** intended to be reused across different data domains (student, course, teacher, etc.).
|
||||
- **Reusable abstractions** (Page Components, Sections, Items, generic composables, base components) **must not contain domain-specific names** (e.g., `Student`, `Course`) in their file names, type names, or export names.
|
||||
- Domain-specific names are **only allowed** in:
|
||||
- `src/models/<domain>.ts` — domain models
|
||||
- `src/stores/<domain>.ts` — domain stores
|
||||
- `src/services/modules/<domain>.ts` — service modules
|
||||
- Examples of correct vs. incorrect naming:
|
||||
- ❌ `PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
|
||||
- ❌ `useStudentMaintenancePage.ts` → ✅ `useSingleRecordMaintenancePage.ts`
|
||||
- ❌ `ItemStudentRow.vue` → ✅ `ItemDataRow.vue`
|
||||
- ❌ `useStudentCrudCommands.ts` → ✅ `useCrudCommands.ts`
|
||||
- ✅ `models/student.ts`, `stores/students.ts` — domain layer, specific names are correct
|
||||
|
||||
## GUIDE.md 寫作規則
|
||||
|
||||
- `src/**/GUIDE.md` 只保留該層/目錄的**約束、慣例與索引**,不要塞入詳細 API 文件。
|
||||
- 當新增 pattern、目錄或慣例影響層邊界時,建立或更新對應的 `src/**/GUIDE.md`,並確保 `docs/llm-development-guide.md` 將其列入索引。
|
||||
- 元件的 Props/Slots/Emits 詳細說明放在各子目錄的 `GUIDE.md`(如 `src/components/base/GUIDE.md`、`src/components/sections/GUIDE.md`),不要放在上層 `src/components/GUIDE.md`。
|
||||
- **新增 page/section 元件時,必須一併描述「視覺特徵」**:說明畫面上出現哪些元素(如標題卡片、按鈕類型、表格位置),讓 LLM 能從截圖或設計稿判斷該用哪個元件。視覺特徵寫在對應子目錄 `GUIDE.md` 的「視覺特徵」小節。
|
||||
|
||||
## Stack
|
||||
- Framework: Vue 3 + Vite
|
||||
- UI Library: Vuetify
|
||||
- Enabled Features: ESLint, Vuetify MCP, Pinia, Vue I18n, Vue Router
|
||||
- Enabled Features: Vuetify MCP, Pinia, Vue I18n, Vue Router
|
||||
|
||||
## Icon Usage
|
||||
```js
|
||||
@@ -26,41 +48,19 @@ import { mdiAccount } from '@mdi/js'
|
||||
</template>
|
||||
```
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
## Vuetify MCP
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
- When looking up Vuetify versions, release notes, component APIs, directive APIs, installation guides, FAQs, feature guides, or package exports, use Vuetify MCP first.
|
||||
- When a question involves Vuetify component props, events, slots, exposed methods, generated DOM, accessibility output, or officially supported extension points, verify with Vuetify MCP before changing the implementation.
|
||||
- Prefer the official API and documentation information returned by MCP. Do not infer Vuetify behavior.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
### 常用工具
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
- `get_release_notes_by_version`: 查詢指定版本或 latest 的 release notes。
|
||||
- `get_component_api_by_version`: 查詢指定 Vuetify 元件的 props、events、slots、exposed methods。
|
||||
- `get_directive_api_by_version`: 查詢指定 Vuetify directive 的 API。
|
||||
- `get_vuetify_api_by_version`: 下載並快取指定版本的 Vuetify API types。
|
||||
- `get_installation_guide`: 查詢 Vite、Nuxt、Laravel、CDN 等安裝方式。
|
||||
- `get_feature_guide`: 查詢 theme、icons、i18n、display、layout 等功能指南。
|
||||
- `get_exposed_exports`: 查詢 Vuetify npm package 可匯出的項目。
|
||||
- `get_frequently_asked_questions`: 查詢 Vuetify FAQ。
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
@@ -1,115 +1,128 @@
|
||||
# skt-vuetify-templates
|
||||
|
||||
Scaffolded with Vuetify CLI.
|
||||
Vue 3 + Vite + Vuetify template,目標是讓新專案可以直接在 `src` 裡新增 views、components、stores、services 與 composables,並讓一般頁面自動被主框架 layout 包住。
|
||||
|
||||
## ❗️ Documentation
|
||||
## Stack
|
||||
|
||||
- Primary docs: https://vuetifyjs.com/
|
||||
- Getting started guide: https://vuetifyjs.com/en/getting-started/installation/
|
||||
- Community support: https://community.vuetifyjs.com/
|
||||
- Issue tracker: https://issues.vuetifyjs.com/
|
||||
|
||||
## 🧱 Stack
|
||||
|
||||
- Framework: Vue 3 + Vite
|
||||
- UI Library: Vuetify
|
||||
- Language: TypeScript
|
||||
- Package manager: pnpm
|
||||
|
||||
## 🧭 Start Here
|
||||
|
||||
- Main entry: `src/main.ts`
|
||||
- Main app component: `src/App.vue`
|
||||
- Main styles: `src/styles/`
|
||||
- Plugin setup: `src/plugins/`
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
- `src/main.ts` — application entry point
|
||||
- `src/App.vue` — root component
|
||||
- `src/components/` — reusable Vue components
|
||||
- `src/plugins/` — plugin registration and setup
|
||||
- `src/styles/` — global styles and theme settings
|
||||
- `public/` — static public files
|
||||
|
||||
## ✨ Enabled Features
|
||||
|
||||
- ESLint
|
||||
- Vuetify MCP
|
||||
- Vue 3 + Vite
|
||||
- Vuetify
|
||||
- TypeScript
|
||||
- Pinia
|
||||
- Vue I18n
|
||||
- Vue Router
|
||||
- Vue I18n
|
||||
- pnpm
|
||||
|
||||
## 💿 Install
|
||||
|
||||
Use your selected package manager (pnpm) to install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 🏗️ Build
|
||||
開發伺服器預設使用 `vite.config.mts` 的 port `3700`。
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
## Template 使用方式
|
||||
|
||||
一般功能開發從 `src` 開始:
|
||||
|
||||
1. 在 `src/views` 或 `src/views/<feature>` 新增頁面。
|
||||
2. 在 `src/router/routes.ts` 新增 route。
|
||||
3. 一般頁面使用 `meta: { layout: 'default' }`。
|
||||
4. 畫面區塊拆到 `src/components/<feature>`。
|
||||
5. 複雜流程放到 `src/composables/<feature>`。
|
||||
6. 跨頁共享狀態放到 `src/stores/*.ts`。
|
||||
7. API module 放到 `src/services/modules/<domain>.ts`。
|
||||
|
||||
更完整的入口說明見 [src/README.md](./src/README.md)。
|
||||
|
||||
## 新增頁面
|
||||
|
||||
最小 route 範例:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'reports',
|
||||
component: () => import('@/views/reports/Reports.vue'),
|
||||
meta: { layout: 'default', requiresAuth: true },
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 Available Scripts
|
||||
完整範例見 [docs/add-page-example.md](./docs/add-page-example.md)。
|
||||
|
||||
## Layout
|
||||
|
||||
`App.vue` 會根據 `route.meta.layout` 選擇 layout:
|
||||
|
||||
- `default`:使用 `src/components/layouts/MainLayout.vue`
|
||||
- `none`:使用 `src/components/layouts/PlainLayout.vue`
|
||||
|
||||
一般功能頁使用 `default`。登入頁、錯誤頁、維護中頁、明確要求獨立顯示的頁面才使用 `none`。
|
||||
|
||||
## API 與環境變數
|
||||
|
||||
複製 `.env.example` 作為本機設定起點:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`client.ts` 會優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
|
||||
|
||||
實際 `.env` 與 `.env.*.local` 不應提交。production API URL 應由使用專案自己的部署環境提供。
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/main.ts`:app entry。
|
||||
- `src/App.vue`:app shell 組裝層,依 route meta 切換 layout。
|
||||
- `src/router`:routes、history、guards。
|
||||
- `src/views`:route views。
|
||||
- `src/components`:layout、page component、feature/domain components。
|
||||
- `src/composables`:可重用流程與 UI state。
|
||||
- `src/stores`:Pinia stores。
|
||||
- `src/services`:HTTP client、API modules、token/session/error。
|
||||
- `src/plugins`:Vuetify、Pinia、I18n、Router 註冊。
|
||||
- `src/styles`:Vuetify SASS settings 與 themes。
|
||||
- `src/language`:i18n JSON。
|
||||
|
||||
## Template Core
|
||||
|
||||
template core 是 app shell、router、layout、plugins、styles、services 基礎設施與全域 stores。一般專案會保留它們。
|
||||
|
||||
## Documentation
|
||||
|
||||
- [src/README.md](./src/README.md):`src` 開發入口。
|
||||
- [docs/frontend-layering.md](./docs/frontend-layering.md):目前前端分層與責任邊界。
|
||||
- [docs/llm-development-guide.md](./docs/llm-development-guide.md):給 LLM 的操作規則。
|
||||
- [docs/add-page-example.md](./docs/add-page-example.md):新增頁面範例。
|
||||
- [src/components/README.md](./src/components/README.md)
|
||||
- [src/services/README.md](./src/services/README.md)
|
||||
- [src/plugins/README.md](./src/plugins/README.md)
|
||||
- [src/styles/README.md](./src/styles/README.md)
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev`
|
||||
- `pnpm build`
|
||||
- `pnpm preview`
|
||||
- `pnpm build-only`
|
||||
- `pnpm type-check`
|
||||
- `pnpm lint`
|
||||
- `pnpm lint:fix`
|
||||
- `pnpm format`
|
||||
- `pnpm mcp`
|
||||
- `pnpm mcp:revert`
|
||||
|
||||
## 💪 Support Vuetify Development
|
||||
## Verification
|
||||
|
||||
This project uses Vuetify - an MIT licensed Open Source project. We are glad to welcome contributors and any support for ongoing development:
|
||||
|
||||
- Contribute to Vuetify and ecosystem projects: https://github.com/vuetifyjs
|
||||
- Request enterprise support: https://support.vuetifyjs.com/
|
||||
- Sponsor on GitHub: https://github.com/sponsors/vuetifyjs
|
||||
- Support on Open Collective: https://opencollective.com/vuetify
|
||||
|
||||
## playwright
|
||||
完成修改後至少執行:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test
|
||||
# Runs the end-to-end tests.
|
||||
|
||||
pnpm exec playwright test --ui
|
||||
# Starts the interactive UI mode.
|
||||
|
||||
pnpm exec playwright test --project=chromium
|
||||
# Runs the tests only on Desktop Chrome.
|
||||
|
||||
pnpm exec playwright test example
|
||||
# Runs the tests in a specific file.
|
||||
|
||||
pnpm exec playwright test --debug
|
||||
# Runs the tests in debug mode.
|
||||
|
||||
pnpm exec playwright codegen
|
||||
# Auto generate tests with Codegen.
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
We suggest that you begin by typing:
|
||||
若變更 route、layout 或主要 UI flow,再啟動 dev server 並用瀏覽器確認。
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test
|
||||
```
|
||||
## Vuetify
|
||||
|
||||
And check out the following files:
|
||||
- ./tests/e2e/example.spec.ts - Example end-to-end test
|
||||
- ./playwright.config.ts - Playwright Test configuration
|
||||
- Vuetify docs: https://vuetifyjs.com/
|
||||
- Installation guide: https://vuetifyjs.com/en/getting-started/installation/
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# 新增頁面範例
|
||||
|
||||
這份文件示範如何用目前 `src/` 慣例新增一個被 `MainLayout` 包住的一般功能頁。
|
||||
|
||||
範例功能:`reports`
|
||||
|
||||
目前新增一般頁面的預設資料流:
|
||||
|
||||
```txt
|
||||
router -> view -> sections/items
|
||||
↓
|
||||
composable -> store -> service
|
||||
```
|
||||
|
||||
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。
|
||||
|
||||
## 1. 新增 view(含 page model)
|
||||
|
||||
簡單頁面的 page model 直接在 view 裡用 `computed` 組裝,不需要額外建立 page driver。
|
||||
|
||||
```vue
|
||||
<!-- src/views/reports/Reports.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSnackbarStore } from '@/stores/snackbar'
|
||||
|
||||
export interface ReportSummary {
|
||||
id: number
|
||||
title: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
const initialRows: ReportSummary[] = [
|
||||
{ id: 1, title: '學生統計', owner: '教務處' },
|
||||
{ id: 2, title: '課程統計', owner: '課務組' },
|
||||
]
|
||||
|
||||
const snackbar = useSnackbarStore()
|
||||
const rows = ref<ReportSummary[]>(initialRows)
|
||||
const pageModel = computed(() => ({
|
||||
title: '報表清單',
|
||||
rows: rows.value,
|
||||
}))
|
||||
|
||||
function openReport(row: ReportSummary) {
|
||||
snackbar.show({ message: `開啟:${row.title}`, color: 'info' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageReports :page="pageModel" @open="openReport" />
|
||||
</template>
|
||||
```
|
||||
|
||||
若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`。
|
||||
|
||||
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`。
|
||||
|
||||
## 2. 加入 route
|
||||
|
||||
route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
|
||||
|
||||
```ts
|
||||
// src/router/routes.ts
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'reports',
|
||||
component: () => import('@/views/reports/Reports.vue'),
|
||||
meta: { layout: 'default', requiresAuth: true },
|
||||
}
|
||||
```
|
||||
|
||||
`layout: 'default'` 會讓頁面被 `MainLayout` 包住。登入頁、錯誤頁、維護中頁才使用 `layout: 'none'`。
|
||||
|
||||
若頁面需要出現在 drawer menu、favorites 或 breadcrumb:
|
||||
|
||||
- menu 來源目前由 `src/stores/menu.ts` 轉換後端選單資料。
|
||||
- breadcrumb 會依 route path、menu/favorite items 與 fallback title 產生。
|
||||
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
|
||||
- 若只是新增 route,通常不需要修改 `MainLayout.vue` 或 `src/shell/*`。
|
||||
|
||||
## 3. 需要 API 時新增 service module
|
||||
|
||||
```ts
|
||||
// src/services/modules/reports.ts
|
||||
import { httpClient } from '../client'
|
||||
|
||||
export interface ReportSummary {
|
||||
id: number
|
||||
title: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export const reportsApi = {
|
||||
list: async () => ({
|
||||
data: await httpClient.get('Reports').json<ReportSummary[]>(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
service 只封裝 HTTP 細節,不持有 UI 狀態。
|
||||
|
||||
`httpClient` 的 `baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`。
|
||||
|
||||
## 4. 需要共享狀態時新增 store
|
||||
|
||||
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
|
||||
|
||||
```ts
|
||||
// src/stores/reports.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { reportsApi, type ReportSummary } from '@/services/modules/reports'
|
||||
|
||||
export const useReportsStore = defineStore('reports', () => {
|
||||
const items = ref<ReportSummary[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await reportsApi.list()
|
||||
items.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
load,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 5. 驗證
|
||||
|
||||
至少執行:
|
||||
|
||||
```bash
|
||||
pnpm -s type-check
|
||||
```
|
||||
|
||||
需要確認建置產物時再執行:
|
||||
|
||||
```bash
|
||||
pnpm -s build
|
||||
```
|
||||
|
||||
若有 route、layout 或主要互動流程變更,再啟動 dev server 並用瀏覽器確認。
|
||||
@@ -0,0 +1,426 @@
|
||||
# 資料流與元件分層優化策略
|
||||
|
||||
## 一、Apple App Store 專案的核心架構特徵
|
||||
|
||||
1 單一業務邏輯門面
|
||||
2 Intent / Action 分離(查詢與命令)
|
||||
3 Page Model 驅動 UI(資料驅動)
|
||||
4 Shelf / Item 分層(容器與內容分離)
|
||||
5 Svelte Context 作為跨層依賴注入
|
||||
6 命令式外殼 + 聲明式 UI
|
||||
|
||||
Read only when needed: [what apple do](./what-apple-do.md)
|
||||
|
||||
## 二、我們專案的現況診斷
|
||||
|
||||
Read only when needed: [analyse now](./analyse-now.md)
|
||||
|
||||
## 三、優化後的資料流策略
|
||||
|
||||
### 3.1 核心資料流(單向 + 集中閘道)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ App Shell │
|
||||
│ (App.vue → Layout → Global Overlays: Snackbar/Dialogs) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ reactive / props
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ View │
|
||||
│ (views/*.vue — 自含 page model、頁面 UI 與 section 組合) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ section data
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Section / Shelf │
|
||||
│ (決定佈局:水平/網格/列表;不關心內部呈現) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ item data
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Item / Atom │
|
||||
│ (純粹內容呈現;透過 provide/inject 取得 domain context) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
橫向:
|
||||
composables → 可重用流程(CRUD state machine、form validation、editable grid)
|
||||
stores → 跨頁共享狀態(auth、menu、favorites、messages)
|
||||
services → HTTP 閘道(只封裝 ky,不持有 UI 狀態)
|
||||
```
|
||||
|
||||
### 3.2 Page Model 作為主要資料單位
|
||||
|
||||
- **新增 `src/models/page.ts`**:定義各頁面的統一介面。
|
||||
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 composable 取得 page model,組裝 section 元件」。
|
||||
- Page model 可以來自:
|
||||
- store(已有快取)
|
||||
- service(直接 API)
|
||||
- composable(組裝多個來源)
|
||||
|
||||
範例:
|
||||
|
||||
```ts
|
||||
// views/maint/Example.vue — 簡單頁面直接在 view 組裝 page model
|
||||
const studentStore = useStudentStore()
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: '單筆資料維護',
|
||||
records: studentStore.students,
|
||||
loading: false,
|
||||
error: null,
|
||||
}))
|
||||
```
|
||||
|
||||
### 3.3 查詢(Query)與命令(Command)分離
|
||||
|
||||
| 類型 | 資料流 | 錯誤處理 | 狀態位置 |
|
||||
|------|--------|----------|----------|
|
||||
| **Query** | `usePageDriver` → `pageModel` → props | `<PageErrorBoundary>` 或 page-level fallback | composable 內部 ref |
|
||||
| **Command** | `executeCommand()` → `await service.action()` → 重新載入 query | snackbar / dialog / field error | composable 內部 ref |
|
||||
|
||||
- Query 對應 App Store 的 **Intent**:取得資料、回傳 model。
|
||||
- Command 對應 App Store 的 **Action**:執行副作用、不回傳 model、觸發 Query 重新整理。
|
||||
|
||||
### 3.4 Promise-based 頁面載入(可選進階)
|
||||
|
||||
```vue
|
||||
<!-- PageResolver.vue — 對齊 App Store 的 PageResolver.svelte -->
|
||||
<template>
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<component :is="pageComponent" :page="resolvedPage" />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<PageLoadingSpinner />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
- 若頁面資料支援 async setup 或 `usePageDriver` 回傳 Promise,可用 Vue `<Suspense>` 達到與 App Store `{#await page}` 類似的效果。
|
||||
- **短期**:維持 reactive ref,但將 loading / error 統一封裝在 `usePageDriver`。
|
||||
- **長期**:當多數頁面都使用統一 driver 後,可考慮引入 Suspense。
|
||||
|
||||
### 3.5 全域狀態 vs 頁面狀態的邊界
|
||||
|
||||
| 狀態類型 | 存放位置 | 生命周期 |
|
||||
|----------|----------|----------|
|
||||
| 認證、選單、語言、主題 | `src/stores/*.ts` | 應用級 |
|
||||
| 搜尋條件、分頁、dialog visible | `src/composables/useXxxPage.ts` | 頁面級(離開頁面可選保留或重置) |
|
||||
| 表單 dirty / validation | `src/composables/useXxxForm.ts` | dialog / form 級 |
|
||||
| 表格排序、過濾 | `src/composables/useXxxTable.ts` | 區塊級 |
|
||||
|
||||
---
|
||||
|
||||
## 四、優化後的元件分層策略
|
||||
|
||||
### 4.1 五層結構
|
||||
|
||||
```
|
||||
src/
|
||||
├── shell/ ← 新增:App Shell(原 App.vue 拆分)
|
||||
│ ├── AppShell.vue ← layout 切換、全域 overlay 掛載點
|
||||
│ ├── GlobalOverlays.vue ← snackbar、確認 dialog、toast
|
||||
│ └── AppTabs.vue ← 頁籤(從 App.vue 抽出)
|
||||
│
|
||||
├── views/ ← 維持:自含頁面,邏輯與 UI 同檔
|
||||
│ └── maint/
|
||||
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + MaintShell 外殼
|
||||
│
|
||||
├── components/
|
||||
│ ├── sections/ ← 新增:Section / Shelf 層
|
||||
│ │ ├
|
||||
│ │ ├── SectionDataTable.vue
|
||||
│ │ └── SectionFormPanel.vue
|
||||
│ │
|
||||
│ ├── items/ ← 新增:Item / Atom 層(領域獨立)
|
||||
│ │ ├── ItemDataRow.vue
|
||||
│ │ └── ItemFormField.vue
|
||||
│ │
|
||||
│ ├── layouts/ ← 維持:App Shell Layout
|
||||
│ │ ├── MainLayout.vue
|
||||
│ │ └── PlainLayout.vue
|
||||
│ │
|
||||
│ └── base/ ← 維持:真正跨頁共用
|
||||
│ └── DraggableDialog.vue
|
||||
│
|
||||
├── composables/
|
||||
│ ├── page-drivers/ ← 新增:頁面資料協調(僅複雜頁面需要)
|
||||
│ │ └── useSingleRecordMaintenancePage.ts
|
||||
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
|
||||
│ │ └── useCrudCommands.ts
|
||||
│ ├── forms/ ← 維持/重組:表單狀態機
|
||||
│ │ └── useForm.ts
|
||||
│ └── layout/ ← 維持
|
||||
│
|
||||
├── models/ ← 新增:領域模型與 Page Union
|
||||
│ ├── page.ts
|
||||
│ └── student.ts
|
||||
│
|
||||
├── stores/ ← 維持:跨頁共享狀態
|
||||
├── services/ ← 維持:HTTP 閘道
|
||||
└── router/ ← 維持:路由與 meta
|
||||
```
|
||||
|
||||
### 4.2 各層職責與規範
|
||||
|
||||
#### Layer 1: App Shell(`src/shell/`)
|
||||
|
||||
- **職責**:layout 切換、全域 overlay(snackbar、dialog)、頁籤容器、事件總線橋接。
|
||||
- **禁止**:頁面專屬業務流程、頁面資料組裝、特定 dialog 內容。
|
||||
- **對齊**:App Store 的 `App.svelte` + `browser.ts` 的 overlay 部分。
|
||||
|
||||
#### Layer 2: Page Driver(`src/views/`)
|
||||
|
||||
- **職責**:
|
||||
1. 呼叫 `useXxxPage()` 取得 `pageModel`。
|
||||
2. 將 `pageModel` 與事件處理器傳給對應的 `PageXxx.vue`。
|
||||
3. 處理 route param 解析(僅限轉換,不含業務邏輯)。
|
||||
- **目標行數**:< 80 行。
|
||||
- **禁止**:大量模板、dialog 定義、form 欄位、直接操作 store。
|
||||
|
||||
```vue
|
||||
<!-- views/maint/SingleRecord.vue(優化後) -->
|
||||
<script setup lang="ts">
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageMaintenance :page="pageModel" />
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Layer 3: View(`src/views/`)
|
||||
|
||||
- **職責**:自含頁面的完整入口 — 組裝 page model、協調 composable、撰寫頁面 template。
|
||||
- **禁止**:頁面 UI 不再拆到另一個 page component 層。
|
||||
- **對齊**:標準 Vue SPA 慣例。
|
||||
|
||||
```vue
|
||||
<!-- views/maint/SingleRecord.vue -->
|
||||
<script setup lang="ts">
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
const { pageModel, searchPanelOpen, commands, search, students, ... } = useSingleRecordMaintenancePage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MaintShell :title="pageModel.title" @create="commands.openAddDialog">
|
||||
<template #table>...</template>
|
||||
</MaintShell>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Layer 4: Section / Shelf(`src/components/sections/`)
|
||||
|
||||
- **職責**:決定「這一區的佈局方式」(水平捲軸、網格、列表、摺疊面板)。
|
||||
- **禁止**:知道上層 page 的業務邏輯、不直接呼叫 API。
|
||||
- **對齊**:App Store 的 `Shelf.svelte`、`ShelfItemLayout.svelte`。
|
||||
|
||||
```vue
|
||||
<!-- SectionDataTable.vue -->
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="resolvedHeaders"
|
||||
:items="records"
|
||||
fixed-header
|
||||
>
|
||||
<template v-for="slot in customSlots" :key="slot.key" #[slot.key]="{ item }">
|
||||
<slot :name="slot.key" :item="item">
|
||||
<!-- 預設 item 渲染 -->
|
||||
<component :is="slot.itemComponent" :data="item" />
|
||||
</slot>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Layer 5: Item / Atom(`src/components/items/`)
|
||||
|
||||
- **職責**:純粹呈現單一資料單位。
|
||||
- **禁止**:知道自己是水平捲軸還是網格、不管理任何狀態。
|
||||
- **對齊**:App Store 的 `BrickItem.svelte`、`LargeLockupItem.svelte`。
|
||||
|
||||
```vue
|
||||
<!-- ItemDataRow.vue -->
|
||||
<template>
|
||||
<div class="d-flex ga-2">
|
||||
<v-chip size="small" :color="statusColor(data.status)">
|
||||
{{ data.status }}
|
||||
</v-chip>
|
||||
<span>{{ data.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 4.3 容器/內容分離的具體規範
|
||||
|
||||
| 場景 | 容器(Section) | 內容(Item) |
|
||||
|------|-----------------|--------------|
|
||||
| 資料表格 | `SectionDataTable`(決定 headers、分頁、排序) | `ItemDataRow`(決定單列呈現) |
|
||||
| 搜尋面板 | `SectionSearchPanel`(決定展開/收合、grid 佈局) | `ItemFormField`(單一輸入框呈現) |
|
||||
| 圖文列表 | `SectionCardGrid`(決定欄數、gap、RWD) | `ItemProductCard`(卡片內容) |
|
||||
| 表單對話框 | `SectionFormPanel`(決定 dialog 外殼、actions) | `ItemFormFieldGroup`(欄位群組) |
|
||||
|
||||
- **原則**:若同一組資料在不同頁面需要「水平捲軸 vs 網格」兩種呈現,只換 Section,不換 Item。
|
||||
|
||||
### 4.4 Provide / Inject 作為跨層依賴注入
|
||||
|
||||
對齊 App Store 的 `getJet()` / `getI18n()`,在 Vue 中建立明確的 inject API:
|
||||
|
||||
```ts
|
||||
// src/providers/page.ts
|
||||
import { inject, provide } from 'vue'
|
||||
import type { PageDriver } from '@/composables/page-drivers/types'
|
||||
|
||||
const PageDriverKey = Symbol('page-driver')
|
||||
|
||||
export function providePageDriver(driver: PageDriver) {
|
||||
provide(PageDriverKey, driver)
|
||||
}
|
||||
|
||||
export function usePageDriverInjected(): PageDriver {
|
||||
const driver = inject(PageDriverKey)
|
||||
if (!driver) throw new Error('usePageDriverInjected called without provider')
|
||||
return driver
|
||||
}
|
||||
```
|
||||
|
||||
- **提供時機**:Page Component (`PageXxx.vue`) 在掛載時 provide。
|
||||
- **使用時機**:深層的 Item 元件需要觸發 page-level action 時 inject,避免 props drilling。
|
||||
- **禁止**:用 provide/inject 傳遞會頻繁變動的 UI 狀態(如 `dialogVisible`)。這類狀態應透過 props + emits。
|
||||
|
||||
### 4.5 Dialog 外層化策略
|
||||
|
||||
將 dialog 從 view 中完全抽出,形成「Dialog Shell + Content Slot」模式:
|
||||
|
||||
```
|
||||
views/xxx.vue
|
||||
└── PageXxx.vue
|
||||
├── SectionDataTable
|
||||
└── SectionFormPanel(dialog shell)
|
||||
├── MntDialogCard(外殼:標題、toolbar、actions)
|
||||
└── ItemFormFieldGroup(內容:欄位)
|
||||
```
|
||||
|
||||
- `SectionFormPanel` 管理 dialog 的開關、mode、loading、saving。
|
||||
- `ItemFormFieldGroup` 純粹呈現欄位,不知道自己在 dialog 裡。
|
||||
- View 中不再出現 `<teleport>`、`<v-overlay>`、`<v-dialog>` 的具體定義。
|
||||
|
||||
---
|
||||
|
||||
## 五、重構優先順序與遷移路徑
|
||||
|
||||
### Phase 1:建立基礎設施(不動既有 view) ✅ 已完成
|
||||
|
||||
1. [x] 新增 `src/models/`:定義領域模型與 Page union type。
|
||||
- `src/models/student.ts`:抽出 `StudentRecord`,`stores/students.ts` 改為 re-export 以保持向後相容。
|
||||
- `src/models/page.ts`:定義 `BasePageModel`、`MaintenancePageModel` 與 `PageModel` union。
|
||||
2. [x] 新增 `src/shell/`:從 `App.vue` 抽出 `GlobalOverlays.vue` 與 `AppTabs.vue`。
|
||||
- `src/shell/AppShell.vue`:layout 切換與全域 overlay 掛載點。
|
||||
- `src/shell/AppTabs.vue`:頁籤管理與 router-view 容器。
|
||||
- `src/shell/GlobalOverlays.vue`:snackbar、搜尋 dialog、訊息 dialog。
|
||||
3. [x] 新增 `src/components/pages/`:建立第一個 `PageMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
|
||||
- 定義 `MaintenancePageModel` props 與 `create/edit/view/delete/search` emits。
|
||||
- 使用 `MaintShell.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
|
||||
4. [x] 新增 `src/composables/page-drivers/`:建立第一個 page driver 範例。
|
||||
- 協調搜尋條件、分頁與 `pageModel`。
|
||||
- 提供 `load()` 與 `resetSearch()` 供 Page Driver 呼叫。
|
||||
- 後續已刪除純包裝型 driver(如 `useMaintenancePage`)。僅當頁面需要協調多個 composable 時才建立 page driver。
|
||||
|
||||
### Phase 2:遷移最厚的 view(SingleRecord.vue) ✅ 已完成
|
||||
|
||||
1. [x] 將 `SingleRecord.vue` 縮減為 route-level Page Driver。
|
||||
- 目前只負責呼叫 `useSingleRecordMaintenancePage()`,並組裝 `PageMaintenance`、`SectionSearchPanel`、`SectionDataTable`、`SectionFormPanel`。
|
||||
- 行數已由 921 行縮減至 52 行,達成 < 80 行目標。
|
||||
2. [x] 將搜尋區塊抽出到 `src/components/sections/SectionSearchPanel.vue`。
|
||||
- 負責搜尋欄位佈局與 reset 事件,不直接操作 store。
|
||||
3. [x] 將表格與分頁抽出到 `src/components/sections/SectionDataTable.vue`。
|
||||
- 負責 headers、資料列 slot、操作按鈕與分頁 footer,透過 emit 回傳 view/edit/delete/page 事件。
|
||||
4. [x] 將 dialog 抽出到 `src/components/sections/SectionFormPanel.vue`。
|
||||
- 包含側邊 overlay、`MntDialogCard`、record navigation toolbar 與確認 dialog。
|
||||
- View 中不再直接定義 `<teleport>`、`<v-overlay>` 或多個確認 dialog。
|
||||
5. [x] 將表單欄位抽出到 `src/components/items/ItemFormFieldGroup.vue`。
|
||||
- 只呈現欄位與欄位錯誤,透過 `v-model` 與 `clear-field-error` 與上層互動。
|
||||
6. [x] 將 CRUD command 流程抽出到 `src/composables/useCrudCommands.ts`。
|
||||
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
|
||||
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
|
||||
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
||||
- `SingleRecord.vue` 不再直接操作 `studentStore`。
|
||||
|
||||
### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
|
||||
|
||||
> 後續簡化時,B/C/EditableGrid 的薄 page driver 已 inline 回 view,只保留有真實複雜邏輯的 driver。
|
||||
|
||||
1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
|
||||
- `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring。
|
||||
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`。
|
||||
- 新增 `src/components/pages/PageEditableGridMaintenance.vue`,保留既有 `src/components/maint/EditableGrid.vue` 作為主要內容元件。
|
||||
2. [x] `MasterDetailA.vue` 依 Page Driver + Page Component 模式重構。
|
||||
- `src/views/maint/MasterDetailA.vue` 縮減為 34 行。
|
||||
- 新增 `src/composables/page-drivers/useMasterDetailAMaintenancePage.ts`。
|
||||
- 新增 `src/components/pages/PageMasterDetailAMaintenance.vue` 承接原本主從維護 UI。
|
||||
3. [x] `MasterDetailB.vue`、`MasterDetailC.vue` 依 Page Driver + Page Component 模式重構。
|
||||
- `src/views/maint/MasterDetailB.vue` 與 `src/views/maint/MasterDetailC.vue` 均縮減為 10 行。
|
||||
- 新增 `src/composables/page-drivers/useMasterDetailBMaintenancePage.ts`、`src/composables/page-drivers/useMasterDetailCMaintenancePage.ts`。
|
||||
- 新增 `src/components/pages/PageMasterDetailBMaintenance.vue`、`src/components/pages/PageMasterDetailCMaintenance.vue`。
|
||||
4. [x] 通用方向已落地為「每頁 page driver + page component」與既有 `useCrudCommands()`。
|
||||
- Phase 3 未再抽出跨 A/B/C 的大型共用 driver,避免在 demo 變體尚未收斂前建立過早抽象。
|
||||
|
||||
### Phase 4:非 maintenance 頁面統一 ✅ 已完成
|
||||
|
||||
> 後續簡化時,Settings/FncPage 的薄 page driver 已 inline 回 view,型別移至 page component 自身。
|
||||
|
||||
1. [x] `Home.vue`、`Settings.vue`、`FncPage.vue` 套用 Page Driver + Page Component 模式。
|
||||
- `src/views/Home.vue` 縮減為 17 行,新增 `src/components/pages/PageHome.vue` 與 `src/composables/page-drivers/useHomePage.ts`。
|
||||
- `src/views/Settings.vue` 縮減為 10 行,新增 `src/components/pages/PageSettings.vue` 與 `src/composables/page-drivers/useSettingsPage.ts`。
|
||||
- `src/views/FncPage.vue` 縮減為 10 行,新增 `src/components/pages/PageFunction.vue` 與 `src/composables/page-drivers/useFunctionPage.ts`。
|
||||
2. [x] `App.vue` 最終只保留 shell 掛載。
|
||||
- `src/App.vue` 縮減為 7 行,只掛載 `AppShell`。
|
||||
- `src/shell/AppShell.vue` 承接 layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
|
||||
- `src/composables/layout/useAppShell.ts` 承接 menu 合併、favorite、breadcrumb、layout action、手動/強制登出流程。
|
||||
|
||||
### Phase 5:移除 Page Component 層 ✅ 已完成
|
||||
|
||||
> 所有 page component 已合併回對應的 view,`src/components/pages/` 目錄已刪除。page driver 簡化為僅複雜頁面才使用的選配層,view 回歸標準 Vue SPA 慣例:自含 page model + 頁面 UI + section 組合。
|
||||
|
||||
---
|
||||
|
||||
## 六、命名規範總結
|
||||
|
||||
| 層級 | 目錄 | 檔名前綴/範例 |
|
||||
|------|------|---------------|
|
||||
| App Shell | `src/shell/` | `AppShell.vue`、`GlobalOverlays.vue` |
|
||||
| View(自含頁面) | `src/views/` | `SingleRecord.vue` |
|
||||
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue`、`SectionSearchPanel.vue` |
|
||||
| Item / Atom | `src/components/items/` | `ItemDataRow.vue`、`ItemFormField.vue` |
|
||||
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
|
||||
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
|
||||
| Page Driver Composable | `src/composables/page-drivers/` | `useSingleRecordMaintenancePage.ts` |
|
||||
| Command Composable | `src/composables/` | `useCrudCommands.ts` |
|
||||
| Form Composable | `src/composables/forms/` | `useForm.ts` |
|
||||
| Domain Store | `src/stores/` | `students.ts`(維持) |
|
||||
| Service Module | `src/services/modules/` | `students.ts`(維持) |
|
||||
| Domain Model | `src/models/` | `student.ts`、`page.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 七、對齊檢查清單(新增/重構時使用)
|
||||
|
||||
- [ ] 這個 view 超過 200 行了嗎?→ 考慮抽出 Section 或 composable。
|
||||
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
|
||||
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
|
||||
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
|
||||
- [ ] 這個狀態需要跨頁共享嗎?→ 是:store;否:composable。
|
||||
- [ ] 這個邏輯是「取得資料」還是「執行動作」?→ query 用 page driver,command 用 command composable。
|
||||
- [ ] 這個元件只服務單一 domain 嗎?→ 是:留在 `components/items/` 或 `components/sections/` 的 domain 子目錄;否:才進 `base/`。
|
||||
- [ ] 這個抽象降低了理解成本嗎?→ 否:不要抽。
|
||||
|
||||
---
|
||||
|
||||
*本文件取代 `docs/frontend-layering.md` 與 `src/components/GUIDE.md` 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。*
|
||||
+228
-283
@@ -2,19 +2,17 @@
|
||||
|
||||
## 目的
|
||||
|
||||
這份文件只描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案、或重構時有一致判斷基準。
|
||||
這份文件描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案或重構時有一致判斷基準。
|
||||
|
||||
重點不是追求理想化架構,而是避免把舊名稱、過渡寫法、或已刪除的結構繼續當成規則。
|
||||
本文件是現況快照;新增功能與重構的細節規範以 `docs/architecture-strategy.md`、`docs/llm-development-guide.md` 與各層 `src/**/GUIDE.md` 為準。
|
||||
|
||||
目前專案的主要責任鏈如下:
|
||||
|
||||
- `router` 決定 route 與 layout meta
|
||||
- `App.vue` 根據 route meta 組裝 app shell 與全域 UI
|
||||
- `views` 承接路由入口與頁面資料協調
|
||||
- `components` 承接 layout、page component、domain component 與較細的 UI 區塊
|
||||
- `composables` 承接可重用流程與 UI state
|
||||
- `stores` 承接跨頁狀態、快取與全域顯示狀態
|
||||
- `services` 承接 HTTP client、API 模組、token 與錯誤處理
|
||||
```txt
|
||||
router -> App.vue -> AppShell -> layout -> view -> page component -> section -> item
|
||||
↓
|
||||
page driver / command composable -> store -> service
|
||||
```
|
||||
|
||||
## 目前目錄的責任邊界
|
||||
|
||||
@@ -22,365 +20,312 @@
|
||||
|
||||
目前路由集中在:
|
||||
|
||||
- [routes.ts](/home/carl/git/skt-vuetify-templates/src/router/routes.ts)
|
||||
- [index.ts](/home/carl/git/skt-vuetify-templates/src/router/index.ts)
|
||||
- [guards.ts](/home/carl/git/skt-vuetify-templates/src/router/guards.ts)
|
||||
- [routes.ts](../src/router/routes.ts)
|
||||
- [index.ts](../src/router/index.ts)
|
||||
- [guards.ts](../src/router/guards.ts)
|
||||
|
||||
責任:
|
||||
|
||||
- 定義 route 與 route meta
|
||||
- 指定頁面使用哪種 layout
|
||||
- 串接導航守衛
|
||||
- 定義 route 與 route meta。
|
||||
- 指定頁面使用哪種 layout。
|
||||
- 串接導航守衛。
|
||||
|
||||
目前 `meta.layout` 已是 app shell 切換的正式入口:
|
||||
目前 `meta.layout` 是 app shell 切換的正式入口:
|
||||
|
||||
- `default` 走 [MainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/MainLayout.vue)
|
||||
- `none` 走 [PlainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/PlainLayout.vue)
|
||||
- `default` 走 [MainLayout.vue](../src/components/layouts/MainLayout.vue)。
|
||||
- `none` 走 [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)。
|
||||
|
||||
### `src/App.vue`
|
||||
### `src/App.vue` 與 `src/shell`
|
||||
|
||||
[App.vue](/home/carl/git/skt-vuetify-templates/src/App.vue) 目前不是單純掛載入口,而是實際的應用組裝層。
|
||||
[App.vue](../src/App.vue) 目前只掛載 [AppShell.vue](../src/shell/AppShell.vue),不再直接承擔全域 UI 組裝。
|
||||
|
||||
目前承擔的責任包含:
|
||||
`src/shell` 是 App Shell 層:
|
||||
|
||||
- 根據 `route.meta.layout` 切換 layout
|
||||
- 組裝 breadcrumb / favorites / menu 等 layout props
|
||||
- 放置全域搜尋結果 dialog
|
||||
- 放置全域訊息中心 dialog
|
||||
- 放置全域 snackbar
|
||||
- 串接 layout event 與路由跳轉
|
||||
- [AppShell.vue](../src/shell/AppShell.vue):layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
|
||||
- [AppTabs.vue](../src/shell/AppTabs.vue):default layout 下的 tabs 與 keep-alive router-view 容器。
|
||||
- [GlobalOverlays.vue](../src/shell/GlobalOverlays.vue):全域 snackbar、搜尋 dialog、訊息 dialog。
|
||||
|
||||
判斷原則:
|
||||
|
||||
- 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可留在 `App.vue`
|
||||
- 只屬於單一路由頁面的對話框或互動,不應堆到 `App.vue`
|
||||
- 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可放在 `src/shell`。
|
||||
- 只屬於單一路由頁面的對話框或互動,不應放進 `src/shell`。
|
||||
- shell 狀態協調優先放在 `src/composables/layout/useAppShell.ts`。
|
||||
|
||||
### `src/views`
|
||||
|
||||
`views` 目前整體方向是「路由入口 + 頁面資料協調 + 頁面事件協調」。
|
||||
`views` 是 route entry,方向是薄層:呼叫 page driver、掛載 page component、協調 route-level 事件。
|
||||
|
||||
目前較薄的 view:
|
||||
目前較典型的薄 view:
|
||||
|
||||
- [Home.vue](/home/carl/git/skt-vuetify-templates/src/views/Home.vue)
|
||||
- [Login.vue](/home/carl/git/skt-vuetify-templates/src/views/Login.vue)
|
||||
- [EditableGrid.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/EditableGrid.vue)
|
||||
- [Forbidden.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/Forbidden.vue)
|
||||
- [ServerError.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/ServerError.vue)
|
||||
- [ServiceUnavailable.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/ServiceUnavailable.vue)
|
||||
- [NetworkError.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/NetworkError.vue)
|
||||
- [Maintenance.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/Maintenance.vue)
|
||||
- [NotFound.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/NotFound.vue)
|
||||
- [FncPage.vue](/home/carl/git/skt-vuetify-templates/src/views/FncPage.vue)
|
||||
- [Home.vue](../src/views/Home.vue)
|
||||
- [Settings.vue](../src/views/Settings.vue)
|
||||
- [FncPage.vue](../src/views/FncPage.vue)
|
||||
- [SingleRecord.vue](../src/views/maint/SingleRecord.vue)
|
||||
- [EditableGrid.vue](../src/views/maint/EditableGrid.vue)
|
||||
- [MasterDetailA.vue](../src/views/maint/MasterDetailA.vue)
|
||||
- [MasterDetailB.vue](../src/views/maint/MasterDetailB.vue)
|
||||
- [MasterDetailC.vue](../src/views/maint/MasterDetailC.vue)
|
||||
|
||||
目前仍偏厚的 view:
|
||||
錯誤頁集中在 `src/views/errors`,通常使用 `meta.layout = 'none'`,並由 [ErrorShell.vue](../src/views/errors/ErrorShell.vue) 共用錯誤頁骨架。
|
||||
|
||||
- [SingleRecord.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/SingleRecord.vue)
|
||||
- [MasterDetailA.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailA.vue)
|
||||
- [MasterDetailB.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailB.vue)
|
||||
- [MasterDetailC.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailC.vue)
|
||||
[Login.vue](../src/views/Login.vue) 是 template core 例外:它仍負責登入頁組合、功能開關、小型提示 dialog 與登入流程協調。登入頁 UI 拆在 `components/login/*`,captcha 與 announcement 流程拆在頂層 login composable。
|
||||
|
||||
`views` 應遵守的原則:
|
||||
|
||||
- 可以持有 route、store、頁面資料組裝、頁面事件協調
|
||||
- 可以管理只屬於該頁的 dialog 顯示狀態
|
||||
- 不應長期承擔大量可抽出的模板片段
|
||||
- 不應把可重用流程直接留在頁面內重複複製
|
||||
- 可以持有 route、page driver 掛載、頁面資料組裝與頁面事件協調。
|
||||
- 可以管理只屬於該頁的小型 dialog 顯示狀態。
|
||||
- 不應長期承擔大型表格、表單、dialog 模板或可重用流程。
|
||||
- 不應直接處理底層 HTTP 細節。
|
||||
|
||||
### `src/components`
|
||||
|
||||
目前 `components` 已經分成幾種不同角色,不能再用單一規則描述。
|
||||
`components` 依角色分層,不再用單一規則描述。
|
||||
|
||||
#### 1. 頁面型元件
|
||||
#### 1. Root page/template components
|
||||
|
||||
目前以下元件實際上扮演 page component:
|
||||
目前仍放在 `src/components` 根目錄的頁面外殼:
|
||||
|
||||
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
|
||||
- [PageIndex.vue](/home/carl/git/skt-vuetify-templates/src/components/PageIndex.vue)
|
||||
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue)
|
||||
- [PageLogin.vue](../src/components/PageLogin.vue)
|
||||
- [PageIndex.vue](../src/components/PageIndex.vue)
|
||||
- [PageMaint.vue](../src/components/PageMaint.vue)
|
||||
|
||||
這些檔案的責任是:
|
||||
這些是既有 template 頁面外殼或登入頁組裝元件。新增一般功能頁時,優先使用 `src/components/pages`。
|
||||
|
||||
- 接收 view 組好的資料與事件
|
||||
- 組裝某個完整頁面的主畫面
|
||||
- 再往下使用較小的子元件或 domain component
|
||||
#### 2. `components/pages`
|
||||
|
||||
命名規則:
|
||||
`components/pages` 是完整頁面主畫面組裝層:
|
||||
|
||||
- 只要是 page component,檔名以 `Page` 為前綴
|
||||
- page component 可以放在 `components` 根目錄
|
||||
- 不要把 page component 丟進 `base`
|
||||
|
||||
#### 2. `components/login`
|
||||
|
||||
登入頁的較細 UI 區塊已集中到:
|
||||
|
||||
- [CreateAccountLink.vue](/home/carl/git/skt-vuetify-templates/src/components/login/CreateAccountLink.vue)
|
||||
- [LoginAnnouncementBoard.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginAnnouncementBoard.vue)
|
||||
- [LoginBrand.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginBrand.vue)
|
||||
- [LoginForm.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginForm.vue)
|
||||
- [LoginHeader.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginHeader.vue)
|
||||
- [LoginIllustration.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginIllustration.vue)
|
||||
- [LoginToolBar.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginToolBar.vue)
|
||||
- [LoginVerify.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginVerify.vue)
|
||||
|
||||
這一層的定位是:
|
||||
|
||||
- 服務 `PageLogin`
|
||||
- 屬於 login 頁面家族
|
||||
- 不是全域 base library
|
||||
|
||||
#### 3. `components/base`
|
||||
|
||||
目前 `components/base` 只剩下:
|
||||
|
||||
- [DraggableDialog.vue](/home/carl/git/skt-vuetify-templates/src/components/base/DraggableDialog.vue)
|
||||
|
||||
目前判斷原則很直接:
|
||||
|
||||
- `base` 只放真正可跨頁重用、且不屬於特定 domain 的元件
|
||||
- 若元件只服務單一頁面家族或單一 domain,優先放回對應資料夾
|
||||
|
||||
#### 4. `components/layouts`
|
||||
|
||||
目前 layout 實作集中於:
|
||||
|
||||
- [MainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/MainLayout.vue)
|
||||
- [PlainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/PlainLayout.vue)
|
||||
- `src/components/layouts/main-layout/*`
|
||||
|
||||
其中 `main-layout/*` 是 `MainLayout` 底下拆出的骨架子元件:
|
||||
|
||||
- [AppBarBreadcrumbCol.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/main-layout/AppBarBreadcrumbCol.vue)
|
||||
- [AppBarFavoritesCol.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/main-layout/AppBarFavoritesCol.vue)
|
||||
- [AppBarTopCol.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/main-layout/AppBarTopCol.vue)
|
||||
- [DrawerDesktopMenu.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/main-layout/DrawerDesktopMenu.vue)
|
||||
- [DrawerMobileFavoritesPanel.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/main-layout/DrawerMobileFavoritesPanel.vue)
|
||||
- [DrawerMobileMenuPanel.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/main-layout/DrawerMobileMenuPanel.vue)
|
||||
|
||||
layout 應只承擔:
|
||||
|
||||
- app shell
|
||||
- drawer / app bar / favorites / breadcrumb 等框架 UI
|
||||
- 與 layout 視覺結構直接相關的互動
|
||||
|
||||
layout 不應承擔:
|
||||
|
||||
- 頁面專屬業務流程
|
||||
- 特定 domain 的資料規則
|
||||
|
||||
#### 5. `components/maint`
|
||||
|
||||
這個目錄目前是最接近 feature folder 的區域,放 maintenance 領域的 page component 與 domain component:
|
||||
|
||||
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue)
|
||||
- [CommonConfirmDialog.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/CommonConfirmDialog.vue)
|
||||
- [EditableGrid.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/EditableGrid.vue)
|
||||
- [MasterFileFormFields.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/MasterFileFormFields.vue)
|
||||
- [MntDialogCard.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/MntDialogCard.vue)
|
||||
- [MntRecordNavToolbar.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/MntRecordNavToolbar.vue)
|
||||
- `master-detail/*`
|
||||
|
||||
`master-detail/*` 目前屬於維護頁專用的較細組件群:
|
||||
|
||||
- [CourseMobilePanel.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/CourseMobilePanel.vue)
|
||||
- [DetailCollapseGropus.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailCollapseGropus.vue)
|
||||
- [DetailFullHeightPanel.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailFullHeightPanel.vue)
|
||||
- [DetailNavigation.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailNavigation.vue)
|
||||
- [DetailSidePanel.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailSidePanel.vue)
|
||||
- [DetailSimpleList.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailSimpleList.vue)
|
||||
|
||||
結論:
|
||||
|
||||
- `components/maint` 主要扮演 maintenance domain component 層
|
||||
- `CommonConfirmDialog` 可以直接在 maintenance 頁或元件使用,不需要再包一層 CRUD dialog aggregator
|
||||
- 若只是維護頁專用子元件,不要搬到 `base`
|
||||
|
||||
### `src/composables`
|
||||
|
||||
目前已明確分成兩組:
|
||||
|
||||
- `composables/layout/*`
|
||||
- `composables/maint/*`
|
||||
|
||||
代表性檔案:
|
||||
|
||||
- [useAdminLayoutState.ts](/home/carl/git/skt-vuetify-templates/src/composables/layout/useAdminLayoutState.ts)
|
||||
- [useThemeToggle.ts](/home/carl/git/skt-vuetify-templates/src/composables/layout/useThemeToggle.ts)
|
||||
- [useMaintenanceCrudFlow.ts](/home/carl/git/skt-vuetify-templates/src/composables/maint/useMaintenanceCrudFlow.ts)
|
||||
- [useStudentMaintenanceForm.ts](/home/carl/git/skt-vuetify-templates/src/composables/maint/useStudentMaintenanceForm.ts)
|
||||
- [useEditableStudentGrid.ts](/home/carl/git/skt-vuetify-templates/src/composables/maint/useEditableStudentGrid.ts)
|
||||
- [useApiCall.ts](/home/carl/git/skt-vuetify-templates/src/composables/useApiCall.ts)
|
||||
|
||||
`composables` 的責任:
|
||||
|
||||
- 放可重用流程
|
||||
- 放可測試的 UI state
|
||||
- 放與模板結構耦合較低的狀態機
|
||||
|
||||
### `src/stores`
|
||||
|
||||
目前 store 已經是正式分層的一部分,而不只是暫時狀態容器。
|
||||
|
||||
代表性檔案:
|
||||
|
||||
- [auth.ts](/home/carl/git/skt-vuetify-templates/src/stores/auth.ts)
|
||||
- [menu.ts](/home/carl/git/skt-vuetify-templates/src/stores/menu.ts)
|
||||
- [breadcrumbs.ts](/home/carl/git/skt-vuetify-templates/src/stores/breadcrumbs.ts)
|
||||
- [favorites.ts](/home/carl/git/skt-vuetify-templates/src/stores/favorites.ts)
|
||||
- [messages.ts](/home/carl/git/skt-vuetify-templates/src/stores/messages.ts)
|
||||
- [snackbar.ts](/home/carl/git/skt-vuetify-templates/src/stores/snackbar.ts)
|
||||
- [loginAnnouncements.ts](/home/carl/git/skt-vuetify-templates/src/stores/loginAnnouncements.ts)
|
||||
- [students.ts](/home/carl/git/skt-vuetify-templates/src/stores/students.ts)
|
||||
- [semesters.ts](/home/carl/git/skt-vuetify-templates/src/stores/semesters.ts)
|
||||
- [PageHome.vue](../src/components/pages/PageHome.vue)
|
||||
- [PageSettings.vue](../src/components/pages/PageSettings.vue)
|
||||
- [PageFunction.vue](../src/components/pages/PageFunction.vue)
|
||||
- [PageMaintenance.vue](../src/components/pages/PageMaintenance.vue)
|
||||
- [PageEditableGridMaintenance.vue](../src/components/pages/PageEditableGridMaintenance.vue)
|
||||
- [PageMasterDetailAMaintenance.vue](../src/components/pages/PageMasterDetailAMaintenance.vue)
|
||||
- [PageMasterDetailBMaintenance.vue](../src/components/pages/PageMasterDetailBMaintenance.vue)
|
||||
- [PageMasterDetailCMaintenance.vue](../src/components/pages/PageMasterDetailCMaintenance.vue)
|
||||
|
||||
責任:
|
||||
|
||||
- 承接跨頁共享狀態
|
||||
- 承接畫面快取與顯示狀態
|
||||
- 作為 view 與 services 之間的狀態收斂點
|
||||
- 接收 view/page driver 組好的資料與事件。
|
||||
- 組裝完整頁面的主要 section 順序。
|
||||
- 再往下使用 sections、items、feature/domain components。
|
||||
|
||||
注意:
|
||||
#### 3. `components/sections`
|
||||
|
||||
- 目前仍存在 `src/stores/stores/*` 的重複目錄
|
||||
- 這不是分層設計的一部分,而是待整理的結構噪音
|
||||
`components/sections` 是頁面區塊容器:
|
||||
|
||||
### `src/services`
|
||||
- [SectionDataTable.vue](../src/components/sections/SectionDataTable.vue)
|
||||
- [SectionFormPanel.vue](../src/components/sections/SectionFormPanel.vue)
|
||||
- [SectionFormPage.vue](../src/components/sections/SectionFormPage.vue)
|
||||
- [SectionQueryPage.vue](../src/components/sections/SectionQueryPage.vue)
|
||||
|
||||
`services` 現在已經是一層明確的資料存取邊界,不應再被視為附屬工具資料夾。
|
||||
責任:
|
||||
|
||||
- 決定區塊布局與區塊互動。
|
||||
- 以 props 接收資料,以 emit 回報事件。
|
||||
- 不知道 route,不直接呼叫 API。
|
||||
|
||||
#### 4. `components/items`
|
||||
|
||||
`components/items` 是欄位群組或單筆資料呈現層:
|
||||
|
||||
- [ItemFormFieldGroup.vue](../src/components/items/ItemFormFieldGroup.vue)
|
||||
|
||||
item 不應知道自己被放在表格、grid、dialog 或頁面哪個位置。
|
||||
|
||||
#### 5. `components/login`
|
||||
|
||||
登入頁的較細 UI 區塊集中在:
|
||||
|
||||
- [CreateAccountLink.vue](../src/components/login/CreateAccountLink.vue)
|
||||
- [LoginAnnouncementBoard.vue](../src/components/login/LoginAnnouncementBoard.vue)
|
||||
- [LoginBrand.vue](../src/components/login/LoginBrand.vue)
|
||||
- [LoginForm.vue](../src/components/login/LoginForm.vue)
|
||||
- [LoginHeader.vue](../src/components/login/LoginHeader.vue)
|
||||
- [LoginIllustration.vue](../src/components/login/LoginIllustration.vue)
|
||||
- [LoginToolBar.vue](../src/components/login/LoginToolBar.vue)
|
||||
- [LoginVerify.vue](../src/components/login/LoginVerify.vue)
|
||||
|
||||
這一層服務 `PageLogin`,不是全域 base library。
|
||||
|
||||
#### 6. `components/maint`
|
||||
|
||||
`components/maint` 是 maintenance demo / domain component 區域:
|
||||
|
||||
- [CommonConfirmDialog.vue](../src/components/maint/CommonConfirmDialog.vue)
|
||||
- [EditableGrid.vue](../src/components/maint/EditableGrid.vue)
|
||||
- [MasterFileFormFields.vue](../src/components/maint/MasterFileFormFields.vue)
|
||||
- [MntDialogCard.vue](../src/components/maint/MntDialogCard.vue)
|
||||
- [MntRecordNavToolbar.vue](../src/components/maint/MntRecordNavToolbar.vue)
|
||||
- `master-detail/*`
|
||||
|
||||
若只是維護頁專用子元件,不要搬到 `base`。
|
||||
|
||||
#### 7. `components/layouts`
|
||||
|
||||
layout 實作集中於:
|
||||
|
||||
- [MainLayout.vue](../src/components/layouts/MainLayout.vue)
|
||||
- [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
|
||||
- `src/components/layouts/main-layout/*`
|
||||
|
||||
layout 只承擔 app shell、drawer、app bar、favorites、breadcrumb 等框架 UI,不承擔頁面專屬業務流程。
|
||||
|
||||
#### 8. `components/base`
|
||||
|
||||
`components/base` 放真正跨頁共用且不屬於特定 domain 的基礎元件:
|
||||
|
||||
- [DraggableDialog.vue](../src/components/base/DraggableDialog.vue)
|
||||
- [BaseFormTextField.vue](../src/components/base/BaseFormTextField.vue)
|
||||
- [BaseFormSelect.vue](../src/components/base/BaseFormSelect.vue)
|
||||
|
||||
只服務單一頁面家族或單一 domain 的元件不要放進 `base`。
|
||||
|
||||
### `src/composables`
|
||||
|
||||
目前 composables 分成:
|
||||
|
||||
- `page-drivers/*`:頁面資料協調與 page model 組裝。
|
||||
- `commands/*`:命令式副作用流程,例如 create/edit/save/delete。
|
||||
- `layout/*`:AppShell / layout 狀態與事件協調。
|
||||
- `maint/*`:maintenance demo 的表單、CRUD、editable grid 狀態。
|
||||
- 頂層 login / utility composable:`useLoginCaptcha.ts`、`useLoginAnnouncements.ts`、`useApiCall.ts`。
|
||||
|
||||
責任:
|
||||
|
||||
- 放可重用流程。
|
||||
- 放可測試的 UI state。
|
||||
- 放與模板結構耦合較低的狀態機。
|
||||
- 不 import component 或 view。
|
||||
|
||||
### `src/stores`
|
||||
|
||||
目前 store 是跨頁共享狀態、快取與全域顯示狀態的正式分層。
|
||||
|
||||
代表性檔案:
|
||||
|
||||
- [client.ts](/home/carl/git/skt-vuetify-templates/src/services/client.ts)
|
||||
- [interceptors.ts](/home/carl/git/skt-vuetify-templates/src/services/interceptors.ts)
|
||||
- [error.ts](/home/carl/git/skt-vuetify-templates/src/services/error.ts)
|
||||
- [http-error.ts](/home/carl/git/skt-vuetify-templates/src/services/http-error.ts)
|
||||
- [http-toast.ts](/home/carl/git/skt-vuetify-templates/src/services/http-toast.ts)
|
||||
- [session.ts](/home/carl/git/skt-vuetify-templates/src/services/session.ts)
|
||||
- [token.ts](/home/carl/git/skt-vuetify-templates/src/services/token.ts)
|
||||
- [auth.ts](../src/stores/auth.ts)
|
||||
- [app.ts](../src/stores/app.ts)
|
||||
- [menu.ts](../src/stores/menu.ts)
|
||||
- [breadcrumbs.ts](../src/stores/breadcrumbs.ts)
|
||||
- [favorites.ts](../src/stores/favorites.ts)
|
||||
- [messages.ts](../src/stores/messages.ts)
|
||||
- [snackbar.ts](../src/stores/snackbar.ts)
|
||||
- [students.ts](../src/stores/students.ts)
|
||||
- [semesters.ts](../src/stores/semesters.ts)
|
||||
|
||||
責任:
|
||||
|
||||
- 承接跨頁共享狀態。
|
||||
- 承接畫面快取與全域顯示狀態。
|
||||
- 作為 view/page driver/composable 與 services 之間的狀態收斂點。
|
||||
|
||||
`app.ts` 目前是空的 Pinia scaffold,尚未承擔實際 app state。
|
||||
|
||||
### `src/services`
|
||||
|
||||
`services` 是 HTTP 與外部 API 邊界。
|
||||
|
||||
代表性檔案:
|
||||
|
||||
- [client.ts](../src/services/client.ts)
|
||||
- [interceptors.ts](../src/services/interceptors.ts)
|
||||
- [error.ts](../src/services/error.ts)
|
||||
- [http-error.ts](../src/services/http-error.ts)
|
||||
- [http-toast.ts](../src/services/http-toast.ts)
|
||||
- [session.ts](../src/services/session.ts)
|
||||
- [token.ts](../src/services/token.ts)
|
||||
- `services/modules/*`
|
||||
|
||||
責任:
|
||||
|
||||
- 提供 HTTP client
|
||||
- 封裝 API 模組
|
||||
- 統一 token、session 與錯誤處理
|
||||
- 提供 `httpClient`。
|
||||
- 封裝 API 模組。
|
||||
- 統一 token、session 與錯誤處理。
|
||||
|
||||
規則:
|
||||
|
||||
- 元件不直接處理底層 HTTP 細節
|
||||
- 可共享的請求流程優先收斂到 store 或 composable,再由它們呼叫 service
|
||||
- 元件不直接處理底層 HTTP 細節。
|
||||
- service module 不持有 UI 狀態。
|
||||
- 可共享的請求流程優先收斂到 store、page driver 或 composable,再由它們呼叫 service。
|
||||
|
||||
## 目前已落地的分層模式
|
||||
|
||||
### 模式 1:`view -> page component -> page family components`
|
||||
### 模式 1:`view -> page driver -> page component`
|
||||
|
||||
已落地頁面:
|
||||
|
||||
- `Login`
|
||||
- `Home`
|
||||
|
||||
目前的穩定模式是:
|
||||
|
||||
- `view` 負責資料準備與事件協調
|
||||
- page component 負責頁面主畫面組裝
|
||||
- 較細的視覺區塊再拆到對應頁面家族資料夾,例如 `components/login/*`
|
||||
|
||||
### 模式 2:`view -> page component / domain components + maint composables`
|
||||
|
||||
已落地區域:
|
||||
|
||||
- `Settings`
|
||||
- `FncPage`
|
||||
- `views/maint/*`
|
||||
- `components/maint/*`
|
||||
- `composables/maint/*`
|
||||
|
||||
這一層目前是 maintenance 領域最清楚的結構:
|
||||
穩定模式:
|
||||
|
||||
- `views/maint/*` 承接 route 與頁面流程協調
|
||||
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue) 承接維護頁共用頁面骨架
|
||||
- `components/maint/*` 承接維護頁專用元件
|
||||
- `composables/maint/*` 承接 CRUD 流程、表單狀態與 editable grid 狀態
|
||||
- view 負責掛載 page driver 與 page component。
|
||||
- page driver 負責 page model、事件與頁面狀態協調。
|
||||
- page component 負責頁面主畫面組裝。
|
||||
|
||||
[EditableGrid.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/EditableGrid.vue) 是目前最接近薄 view 的 maintenance 頁面。
|
||||
### 模式 2:`Login.vue -> PageLogin -> login components/composables`
|
||||
|
||||
### 模式 3:`router meta -> App.vue -> layout`
|
||||
登入頁是 template core,功能開關集中在 `Login.vue`:
|
||||
|
||||
- `withCaptcha`
|
||||
- `withAnnouncement`
|
||||
- `withForgotPassword`
|
||||
- `withRememberAccount`
|
||||
|
||||
資料流與 side effect 分別由 `useLoginCaptcha()`、`useLoginAnnouncements()`、`PageLogin` 與 `LoginForm` 承接。
|
||||
|
||||
### 模式 3:`router meta -> AppShell -> layout`
|
||||
|
||||
這一層已正式成立:
|
||||
|
||||
- route 決定 layout 類型
|
||||
- `App.vue` 決定套用哪個 shell
|
||||
- layout 專注在骨架與共用框架 UI
|
||||
|
||||
這代表 layout 的責任邊界不應再回頭混入頁面內部流程。
|
||||
- route 決定 layout 類型。
|
||||
- `AppShell` 決定套用哪個 shell layout。
|
||||
- layout 專注在骨架與共用框架 UI。
|
||||
|
||||
## 命名規則
|
||||
|
||||
### 頁面與 page component
|
||||
|
||||
- 直接被 route 載入的檔案放 `views`
|
||||
- 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴
|
||||
- page component 不放進 `base`
|
||||
|
||||
目前例子:
|
||||
|
||||
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
|
||||
- [PageIndex.vue](/home/carl/git/skt-vuetify-templates/src/components/PageIndex.vue)
|
||||
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue)
|
||||
- 直接被 route 載入的檔案放 `views`。
|
||||
- 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴。
|
||||
- page component 優先放 `components/pages`;既有 template 外殼可保留在 `components` 根目錄。
|
||||
- page component 不放進 `base`。
|
||||
|
||||
### 資料夾命名
|
||||
|
||||
- 多字資料夾一律使用 `kebab-case`
|
||||
- 不新增 `snake_case` 或 `PascalCase` 資料夾
|
||||
- 多字資料夾一律使用 `kebab-case`。
|
||||
- 不新增 `snake_case` 或 `PascalCase` 資料夾。
|
||||
|
||||
目前例子:
|
||||
|
||||
- `main-layout`
|
||||
- `master-detail`
|
||||
- `page-drivers`
|
||||
|
||||
### domain component 命名
|
||||
### component 命名
|
||||
|
||||
- 與特定領域強綁定的元件,優先用領域意圖命名
|
||||
- 不要為了抽象而保留含糊的舊前綴
|
||||
- 若元件只在 maint 領域使用,就留在 `components/maint`
|
||||
|
||||
## 目前仍待整理的區域
|
||||
|
||||
### 高優先度
|
||||
|
||||
- 繼續瘦身:
|
||||
- [SingleRecord.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/SingleRecord.vue)
|
||||
- [MasterDetailA.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailA.vue)
|
||||
- [MasterDetailB.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailB.vue)
|
||||
- [MasterDetailC.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailC.vue)
|
||||
|
||||
原因:
|
||||
|
||||
- 這些頁面仍保留較多頁面內資料轉換與事件協調
|
||||
|
||||
### 中優先度
|
||||
|
||||
- 清理 `src/stores/stores/*` 重複結構
|
||||
- 檢查 `components/maint` 內是否仍有可再明確命名的舊名稱
|
||||
- 視 `PageMaint` 的後續演進,決定是否維持在 `components` 根目錄
|
||||
|
||||
### 中低優先度
|
||||
|
||||
- 持續檢查 `views` 是否有可再下放到 page component 的模板片段
|
||||
- 清理命名調整後留下的空資料夾或死連結
|
||||
- Page component:`PageXxx.vue`
|
||||
- Section component:`SectionXxx.vue`
|
||||
- Item component:`ItemXxx.vue`
|
||||
- Base component:不使用 `Page` / `Section` / `Item` 前綴,直接以功能命名。
|
||||
|
||||
## 新增或修改檔案時的判斷準則
|
||||
|
||||
1. 這個檔案是否直接被 route 載入?
|
||||
- 是:優先放 `views`
|
||||
- 是:優先放 `views`。
|
||||
2. 這個檔案是否負責某個完整頁面的主畫面組裝?
|
||||
- 是:用 `Page` 前綴,放 page component 層,不要塞進 `base`
|
||||
- 是:用 `Page` 前綴,優先放 `components/pages`,不要塞進 `base`。
|
||||
3. 這段重複的是模板還是流程?
|
||||
- 模板:抽元件
|
||||
- 流程:抽 composable 或 store
|
||||
- 模板:抽元件。
|
||||
- 流程:抽 composable、page driver、command 或 store。
|
||||
4. 這個狀態是否跨頁共享,或需要快取 / 全域顯示控制?
|
||||
- 是:優先考慮 store
|
||||
- 是:優先考慮 store。
|
||||
5. 這個邏輯是否在處理 API、token、session、錯誤正規化?
|
||||
- 是:放 `services`
|
||||
6. 這個元件是否只屬於單一 domain?
|
||||
- 是:優先放到該 domain 目錄,例如 `components/maint`
|
||||
- 是:放 `services`。
|
||||
6. 這個元件是否只屬於單一 domain 或單一頁面家族?
|
||||
- 是:優先放到該 domain / feature 目錄,例如 `components/maint` 或 `components/login`。
|
||||
7. 這個抽象是否真的降低重複與理解成本?
|
||||
- 否:不要抽
|
||||
- 否:不要抽。
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# LLM 開發操作指南
|
||||
|
||||
## 文件目的
|
||||
|
||||
本專案是給其他 Vue/Vuetify 專案使用的 template。LLM 協助修改時,預設應在 `src` 底下依分層規則新增或修改頁面、元件、store、service 與 composable。
|
||||
|
||||
本文件只保留全域操作順序與導覽。各層細節規範放在 `src/**/GUIDE.md`,避免重複維護。
|
||||
|
||||
## 建議閱讀順序
|
||||
|
||||
1. `src/GUIDE.md`
|
||||
2. `docs/architecture-strategy.md`
|
||||
3. 依 `maintenanceContract.pageKind` 閱讀對應的 demo 與 `src/**/GUIDE.md`(查 `docs/architecture-strategy.md` 的分層說明)
|
||||
4. `docs/add-page-example.md`(需要新增頁面時)
|
||||
|
||||
`frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md` 與 `src/**/GUIDE.md` 為準。
|
||||
|
||||
## GUIDE 索引
|
||||
|
||||
| 範圍 | 指南 |
|
||||
|------|------|
|
||||
| `src` 總覽、資料流、template core、demo 邊界 | `src/GUIDE.md` |
|
||||
| route view 與薄 view 規則 | `src/views/GUIDE.md` |
|
||||
| maintenance demo view | `src/views/maint/GUIDE.md` |
|
||||
| Vue component 分層 | `src/components/GUIDE.md` |
|
||||
| base 元件 | `src/components/base/GUIDE.md` |
|
||||
| section 元件 | `src/components/sections/GUIDE.md` |
|
||||
| layout 邊界 | `src/components/layouts/GUIDE.md` |
|
||||
| page driver、command、layout composable | `src/composables/GUIDE.md` |
|
||||
| route 與 guard | `src/router/GUIDE.md` |
|
||||
| AppShell、tabs、global overlays | `src/shell/GUIDE.md` |
|
||||
| Pinia store | `src/stores/GUIDE.md` |
|
||||
| HTTP service / ky / API module | `src/services/GUIDE.md` |
|
||||
| domain model 與 page model 型別 | `src/models/GUIDE.md` |
|
||||
| 跨模組共用 API 型別 | `src/types/GUIDE.md` |
|
||||
| i18n 文案 | `src/language/GUIDE.md` |
|
||||
|
||||
## 預設修改策略
|
||||
|
||||
一般功能需求優先修改:
|
||||
|
||||
- `src/views/*`
|
||||
- `src/components/sections/*`
|
||||
- `src/components/items/*`
|
||||
- `src/composables/page-drivers/*`
|
||||
- `src/composables/useCrudCommands.ts`
|
||||
- `src/stores/*`
|
||||
- `src/services/modules/*`
|
||||
- `src/router/routes.ts`
|
||||
- `src/language/*.json`
|
||||
|
||||
除非使用者明確要求,避免先修改 template core。template core 清單與 demo/example 邊界見 `src/GUIDE.md`。
|
||||
|
||||
## 常用判斷
|
||||
|
||||
- 新 route:讀 `src/router/GUIDE.md`。
|
||||
- 一般頁面:讀 `src/views/GUIDE.md`、`src/components/GUIDE.md`、`src/composables/GUIDE.md`。
|
||||
- 維護頁:讀 `src/views/maint/GUIDE.md`。
|
||||
- 查詢/列表頁(篩選 + 表格):讀 `src/components/sections/GUIDE.md`(`SectionQueryPage`)。
|
||||
- 申請/填寫頁(送出按鈕):讀 `src/components/sections/GUIDE.md`(`SectionFormPage`)。
|
||||
- layout / AppShell / tabs / global overlay:讀 `src/shell/GUIDE.md` 與 `src/components/layouts/GUIDE.md`。
|
||||
- API 串接:讀 `src/services/GUIDE.md`。
|
||||
- 跨頁共享狀態:讀 `src/stores/GUIDE.md`。
|
||||
- 定義 page model 或 domain model 型別:讀 `src/models/GUIDE.md`。
|
||||
- 共用 API 型別定義:讀 `src/types/GUIDE.md`。
|
||||
- 錯誤頁:讀 `src/views/GUIDE.md`(ErrorShell 模式)與 `src/router/GUIDE.md`(錯誤頁路由慣例)。
|
||||
- 語系文案:讀 `src/language/GUIDE.md`。
|
||||
|
||||
## 修改前檢查
|
||||
|
||||
- 是否碰到 template core。
|
||||
- 是否已有同類型範例可沿用。
|
||||
- 是否需要新增 route。
|
||||
- 是否應拆成 section / item。
|
||||
- 是否應新增 page driver 或 command composable。
|
||||
- 是否需要 store,或只需要頁面內 state。
|
||||
- 是否應定義新的 model 型別(`src/models/`)。
|
||||
- 是否需要更新語系、menu、breadcrumb、favorites。
|
||||
|
||||
## 從視覺特徵選擇 section 元件
|
||||
|
||||
當收到 prototype 截圖或設計稿時,依畫面特徵選擇 section 外殼:
|
||||
|
||||
| 特徵 | 選擇 |
|
||||
|------|------|
|
||||
| 有「送出/存檔」按鈕,且畫面為填寫表單(欄位 + 配合事項 + 動作按鈕) | `SectionFormPage` |
|
||||
| 有「查詢」按鈕,且畫面為篩選條件 + 結果表格/列表 | `SectionQueryPage` |
|
||||
| 純粹表格列表(無送出/查詢按鈕,只有 CRUD 操作) | 不用 section 外殼,直接組合 `v-data-table` |
|
||||
| 混合結構(有查詢也有表單填寫) | 評估是否拆成兩頁;若必須同頁,不用通用外殼 |
|
||||
|
||||
判斷順序:先看有無「送出/存檔」→ 再看有無「查詢」→ 其餘視為一般列表頁。
|
||||
|
||||
## `.spec.json` 對照指南
|
||||
|
||||
當 LLM 依照 `GEN-FE-PROMPT` 讀取 `.ht/spec/{page}.spec.json` 後,依 `maintenanceContract.pageKind` 決定對應的 demo 與 composable 界面,再將 `.spec.json` 的 evidence 欄位對應到 composable 的 reactive state、computed 與 API calls。
|
||||
|
||||
### query(查詢頁)→ `SectionQueryPage`
|
||||
|
||||
參考:`src/views/demos/SectionQueryPageDemo.vue`、`src/composables/page-drivers/useSectionsDemoPage.ts`
|
||||
|
||||
架構:
|
||||
```
|
||||
View(自含 page model + UI) → SectionQueryPage
|
||||
↓
|
||||
composable (page driver)
|
||||
```
|
||||
|
||||
**composable 必須回傳:**
|
||||
|
||||
| 名稱 | 型別 | 對應 `.spec.json` 來源 |
|
||||
|------|------|------------------------|
|
||||
| `queryFilters` | `Ref<{ 每個欄位 }>` | `pageContract.forms[0].fields` — 每個 field 建一個 key,型別依 `field.type`(text→string, select→string \| null,選項取自 `field.options`) |
|
||||
| `pageModel` | `ComputedRef<{ title, ... }>` | `title` 來自 `pageContract.title`;`backLabel` 固定為 `'返回'` |
|
||||
| `handleQuerySearch()` | 函式 | 觸發 `apiContract.endpoints` 中 `usage=search` 的 API call;呼叫時機對應 `bddContract.scenarios` 中 `type=query` 的 When |
|
||||
| `handleQueryBack()` | 函式 | 對應 `pageContract.actions` 中 `actionType=back` |
|
||||
| 表格資料 | 在 `pageModel` 中 | `tables[].headers` 對應表格欄;`sampleRows` 對應欄位格式 |
|
||||
|
||||
**page component props:**
|
||||
- `v-model:query-filters` — 雙向綁定 `queryFilters`
|
||||
- `:page` — 傳入 `pageModel`
|
||||
|
||||
**page component emits:**
|
||||
- `@search` → 呼叫 `handleQuerySearch`
|
||||
- `@back` → 呼叫 `handleQueryBack`
|
||||
|
||||
### application(申請/表單頁)→ `SectionFormPage`
|
||||
|
||||
參考:`src/views/demos/SectionFormPageDemo.vue`
|
||||
|
||||
架構:
|
||||
```
|
||||
View(自含 page model + UI) → SectionFormPage
|
||||
↓
|
||||
composable (page driver)
|
||||
```
|
||||
|
||||
**composable 必須回傳:**
|
||||
|
||||
| 名稱 | 型別 | 對應 `.spec.json` 來源 |
|
||||
|------|------|------------------------|
|
||||
| `demoForm`(應改名為 `formState`) | `Ref<{ 每個欄位 }>` | `pageContract.forms[0].fields` — text/select 建 key;不可編輯的 `readonly` 欄位不放進 formState,改從 `pageModel` 單向顯示 |
|
||||
| `pageModel` | `ComputedRef` | `title` 來自 `pageContract.title` |
|
||||
| `handleFormSubmit()` | 函式 | 觸發 `apiContract.endpoints` 中 `usage=create` 的 POST endpoint;呼叫前驗證 `apiCatalog.fieldRules`;呼叫時機對應 `bddContract.scenarios` 中 `type=application-submit` 的 When/Then |
|
||||
| `resetDemoForm()`(改為 `resetForm`) | 函式 | 對應 `pageContract.actions` 中 `actionType=reset` |
|
||||
| `handleFormBack()` | 函式 | 對應 `actionType=back` |
|
||||
|
||||
**提交 payload 規則:**
|
||||
- `apiCatalog.fieldRules` 中的 `field` 與 `rule` 決定必填、長度、格式驗證
|
||||
- 型別轉換依 `field.type`:number 欄位不可包成 string 送出
|
||||
|
||||
### maintenance(維護/CRUD 頁)→ `maint/*`
|
||||
|
||||
參考:`src/views/maint/README.md` — 依資料結構選擇最接近的範本(EditableGrid / SingleRecord / MasterDetail A/B/C)
|
||||
|
||||
**composable 必須回傳:**
|
||||
|
||||
| 名稱 | 對應 `.spec.json` 來源 |
|
||||
|------|------------------------|
|
||||
| `search filters` | `pageContract.forms[0].fields` |
|
||||
| `table data / headers` | `pageContract.tables[].headers` + search API response |
|
||||
| `row action handlers` | `maintenanceContract.rowActions` — 每個 action 對應一個 handler;`enabledWhen` 決定啟用條件(如 `aprvYn === 'Z'` 時才能修改) |
|
||||
| `create/update/delete calls` | `apiContract.endpoints` 中對應的 POST/PUT/DELETE |
|
||||
|
||||
**row action 狀態規則:**
|
||||
- `enabledWhen` 直接轉為 template 中的 `:disabled` 或 `v-if` 條件
|
||||
- `maintenanceContract.businessRules` 中的額外限制一併套用
|
||||
|
||||
### 通用規則
|
||||
|
||||
**entity 命名:** 所有 composable、component、store 的名稱以 `maintenanceContract.dataModel.primaryEntity` 為 entity 名,例如 primaryEntity=`FacilityApply` → `useFacilityApplyPage.ts` → `PageFacilityApply.vue`。
|
||||
|
||||
**API 串接:** 在 `src/services/modules/` 新增對應 entity 的 API module,method 名稱對齊 `apiContract.endpoints[].usage`(search/create/update/delete/print),path 對齊 `endpoint.path`。
|
||||
|
||||
**錯誤處理:** 檢查 `apiContract.errorHandling.format` — 若為 `ProblemDetailsWithValidationErrors`,須處理 `errors` 物件中的逐欄錯誤訊息;若為 `ProblemDetails`,只顯示 `detail`。
|
||||
|
||||
**語系文案:** 欄位 label 與按鈕文字取自 `pageContract.forms[].fields[].label` 和 `pageContract.actions[].label`,放入 `src/language/` 對應語系 key。
|
||||
|
||||
## 完成前驗證
|
||||
|
||||
- Vue / TypeScript 結構有變更:`pnpm -s type-check`
|
||||
- 需要確認產物可建置:`pnpm -s build`
|
||||
- Markdown 或大量搬移:`git diff --check`
|
||||
- route、layout 或主要畫面流程有變更:啟動 dev server 並用瀏覽器確認,除非使用者明確不需要。
|
||||
|
||||
如果無法執行驗證,回報原因,不要宣稱已驗證。
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import 'axios'
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
@@ -13,17 +12,3 @@ declare module 'vue-router' {
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
meta?: {
|
||||
silentToast?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface InternalAxiosRequestConfig {
|
||||
meta?: {
|
||||
silentToast?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
import vuetify from 'eslint-config-vuetify'
|
||||
|
||||
export default vuetify({
|
||||
ts: true,
|
||||
},{
|
||||
extends: [eslintConfigPrettier],
|
||||
rules: {
|
||||
'vue/no-required-prop-with-default': 'off',
|
||||
'vue/attributes-order': 'off',
|
||||
'@typescript-eslint/unified-signatures': 'off',
|
||||
'@typescript-eslint/member-ordering': 'off',
|
||||
'unicorn/prefer-query-selector': 'off',
|
||||
'unicorn/no-array-sort':'off',
|
||||
'unicorn/prefer-logical-operator-over-ternary': 'off',
|
||||
'perfectionist/sort-named-imports': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'off'
|
||||
}
|
||||
})
|
||||
+2
-12
@@ -9,15 +9,11 @@
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint --cache",
|
||||
"lint:fix": "eslint --fix --cache",
|
||||
"format": "prettier . --write",
|
||||
"mcp": "ruler apply",
|
||||
"mcp:revert": "ruler revert"
|
||||
"format": "prettier . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"axios": "^1.13.6",
|
||||
"ky": "^2.0.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-i18n": "^11.3.0",
|
||||
@@ -25,16 +21,10 @@
|
||||
"vuetify": "^4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@intellectronica/ruler": "^0.3.37",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tsconfig/node22": "^22.0.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-config-vuetify": "^4.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "^3.8.1",
|
||||
"sass-embedded": "^1.98.0",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
Generated
+10
-1894
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"playwright": {
|
||||
"source": "openai/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "595a334de3bd5335f442a33d1bdefb01207c88c99fe43e3bd99b0d5435f230de"
|
||||
},
|
||||
"playwright-generate-test": {
|
||||
"source": "github/awesome-copilot",
|
||||
"sourceType": "github",
|
||||
"computedHash": "8f963da3e25f705ff39755b8b990f20da0c0320cb86ac83fcc4d2d4eca91f6e4"
|
||||
},
|
||||
"vue-best-practices": {
|
||||
"source": "antfu/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "d7d22c8cb343583c3904692c4d1d7b50382945e433e4f6e053f4aabb9846cbc3"
|
||||
},
|
||||
"vue-router-best-practices": {
|
||||
"source": "antfu/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "e27384f4e6c8c70a612e76b74e4387efb8e291a6a1e3aa14a69102a4ce4b4654"
|
||||
},
|
||||
"vue-testing-best-practices": {
|
||||
"source": "antfu/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "18c7d8f42f350f927e37de055e34c97b8cfb9f79c12cf942f7f3d2a0821057b5"
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-588
@@ -1,589 +1,7 @@
|
||||
<template>
|
||||
<!-- 根據路由設定 meta.layout 動態切換佈局 -->
|
||||
<component
|
||||
:is="activeLayout"
|
||||
v-bind="layoutProps"
|
||||
v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
|
||||
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
|
||||
v-model:is-rail="menuStore.isRail"
|
||||
@action="handleLayoutAction"
|
||||
@logout="handleLogout"
|
||||
@remove-favorite="handleRemoveFavorite"
|
||||
@search="handleSearch"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<template #breadcrumb-actions>
|
||||
<v-btn
|
||||
color="secondary"
|
||||
:disabled="isFavoriteActionDisabled"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
|
||||
{{ favoriteActionLabel }}
|
||||
</v-btn>
|
||||
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
|
||||
<v-icon class="mr-1" size="14" :icon="mdiHome" />
|
||||
返回首頁
|
||||
</v-btn>
|
||||
</template>
|
||||
<!-- 如果是預設佈局,顯示分頁標籤 -->
|
||||
<template v-if="showTabs">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<v-tabs
|
||||
v-model="activeTab"
|
||||
bg-color="background"
|
||||
color="primary"
|
||||
density="compact"
|
||||
show-arrows
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
|
||||
{{ tab.title }}
|
||||
<v-btn
|
||||
aria-label="關閉頁籤"
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
density="compact"
|
||||
:disabled="tabs.length <= 1"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click.prevent.stop="closeTab(tab.path)"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</v-btn>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="flex-grow-1 overflow-auto" style="min-height: 0" tabindex="0">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他佈局直接顯示內容 -->
|
||||
<router-view v-else />
|
||||
</component>
|
||||
|
||||
<v-dialog v-model="searchDialog" max-width="640">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
|
||||
<v-card-subtitle v-if="searchKeyword" class="pt-4"
|
||||
>關鍵字:{{ searchKeyword }}</v-card-subtitle
|
||||
>
|
||||
<v-card-text class="pt-2">
|
||||
<v-alert
|
||||
v-if="searchResults.length === 0"
|
||||
class="mt-2"
|
||||
density="compact"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
>
|
||||
查無結果
|
||||
</v-alert>
|
||||
<v-list v-else density="compact">
|
||||
<v-list-item
|
||||
v-for="item in searchResults"
|
||||
:key="item.path"
|
||||
class="mb-2"
|
||||
@click="handleSearchSelect(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.parents?.length">
|
||||
{{ item.parents.join(' / ') }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="text" @click="searchDialog = false">關閉</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!--
|
||||
訊息中心 Dialog:
|
||||
放在 App.vue 的原因是要被「首頁卡片」與「頂部工具列訊息按鈕」共同觸發,
|
||||
並且避免在 layout/template 層放入業務 UI,維持模板的純展示特性。
|
||||
-->
|
||||
<v-dialog v-model="messageStore.isOpen" max-width="720">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
|
||||
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis"
|
||||
>僅示意資料,不含延伸功能</v-card-subtitle
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<!--
|
||||
使用 v-data-iterator 進行資料展示,
|
||||
這樣若未來要加排序或分頁,不需改動結構。
|
||||
-->
|
||||
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
|
||||
<template #default="{ items }">
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="wrapped in items"
|
||||
:key="resolveMessageItem(wrapped).id"
|
||||
border="sm"
|
||||
class="pa-2 mb-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar color="primary" size="28" variant="tonal">
|
||||
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2 font-weight-medium">
|
||||
{{ resolveMessageItem(wrapped).title }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-medium-emphasis">
|
||||
{{ resolveMessageItem(wrapped).meta }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-data-iterator>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="messageStore.close()">關閉</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="snackbar.visible"
|
||||
:color="snackbar.color"
|
||||
:location="snackbar.location"
|
||||
:timeout="snackbar.timeout"
|
||||
:variant="snackbar.variant"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
mdiBellOutline,
|
||||
mdiCalendarOutline,
|
||||
mdiClose,
|
||||
mdiCloseCircle,
|
||||
mdiCog,
|
||||
mdiFileDocumentOutline,
|
||||
mdiFileTreeOutline,
|
||||
mdiHome,
|
||||
mdiHomeCityOutline,
|
||||
mdiPlusCircle,
|
||||
mdiSchoolOutline,
|
||||
mdiTableEdit,
|
||||
} from '@mdi/js'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MainLayout from '@/components/layouts/MainLayout.vue'
|
||||
import PlainLayout from '@/components/layouts/PlainLayout.vue'
|
||||
import { HTTP_TOAST_EVENT } from './services/http-toast'
|
||||
import { SESSION_FORCE_LOGOUT_EVENT } from './services/session'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useBreadcrumbStore } from './stores/breadcrumbs'
|
||||
import { useFavoritesStore } from './stores/favorites'
|
||||
import { useMenuStore } from './stores/menu'
|
||||
import { useMessageStore } from './stores/messages'
|
||||
import { useSnackbarStore } from './stores/snackbar'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const snackbar = useSnackbarStore()
|
||||
const authStore = useAuthStore()
|
||||
const menuStore = useMenuStore()
|
||||
const breadcrumbStore = useBreadcrumbStore()
|
||||
const favoritesStore = useFavoritesStore()
|
||||
// 訊息中心:集中控制 dialog 顯示狀態
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
// 固定選單(合併到 API 回傳的選單)
|
||||
const _fixedMenuItems = [
|
||||
{
|
||||
title: '資料維護',
|
||||
navigable: false,
|
||||
subItems: [
|
||||
{ title: '單筆資料維護', icon: mdiFileDocumentOutline, path: '/single-record-maintenance' },
|
||||
{ title: '主從資料維護A', icon: mdiFileTreeOutline, path: '/master-detail-maintenance' },
|
||||
{ title: '主從資料維護B', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-b' },
|
||||
{ title: '主從資料維護C', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-c' },
|
||||
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
|
||||
],
|
||||
},
|
||||
{ title: '登入頁', path: '/login' },
|
||||
]
|
||||
|
||||
// 範例選單(用於 tab 顯示名稱的保底資料)
|
||||
const _menuItemsExample = [
|
||||
{ title: '首頁', icon: mdiHome, path: '/' },
|
||||
{
|
||||
title: '設定',
|
||||
icon: mdiCog,
|
||||
path: '/settings',
|
||||
navigable: false,
|
||||
},
|
||||
..._fixedMenuItems,
|
||||
]
|
||||
|
||||
/**
|
||||
* 佈局對映表
|
||||
*/
|
||||
const layoutMap = {
|
||||
default: MainLayout,
|
||||
none: PlainLayout,
|
||||
}
|
||||
|
||||
// 取得當前應使用的組件
|
||||
const activeLayout = computed(() => {
|
||||
return layoutMap[route.meta.layout] || MainLayout
|
||||
})
|
||||
|
||||
function buildMergedMenuItems(items) {
|
||||
const flatPaths = new Set()
|
||||
const collectPaths = (list) => {
|
||||
for (const item of list || []) {
|
||||
if (item?.path) flatPaths.add(item.path)
|
||||
if (item?.subItems?.length) collectPaths(item.subItems)
|
||||
}
|
||||
}
|
||||
|
||||
collectPaths(items)
|
||||
|
||||
const mergeFixedItems = (list) => {
|
||||
return (list || []).map((item) => {
|
||||
if (!item?.subItems?.length) return item
|
||||
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
|
||||
return { ...item, subItems }
|
||||
})
|
||||
}
|
||||
|
||||
const fixedItems = mergeFixedItems(_fixedMenuItems).filter((item) => {
|
||||
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
|
||||
return item.subItems.length > 0
|
||||
})
|
||||
|
||||
return [...(items || []), ...fixedItems]
|
||||
}
|
||||
|
||||
// 根據不同 Layout 傳遞不同的 Props
|
||||
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
|
||||
|
||||
const mergedFavoriteItems = computed(() => {
|
||||
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
|
||||
const seen = new Set()
|
||||
return combined.filter((item) => {
|
||||
const key = item.path ?? item.title
|
||||
if (!key) return false
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const layoutProps = computed(() => {
|
||||
const layout = route.meta.layout
|
||||
if (layout === 'default') {
|
||||
return {
|
||||
systemTitle: '測試環境',
|
||||
favoriteItems: mergedFavoriteItems.value,
|
||||
menuItems: mergedMenuItems.value,
|
||||
breadcrumbItems: breadcrumbStore.breadcrumbItems,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
function handleSelect(item) {
|
||||
console.log('Selected:', item)
|
||||
if (item.path) {
|
||||
router.push(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
const searchDialog = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref([])
|
||||
|
||||
function buildSearchResults(items, keyword, parents = []) {
|
||||
const results = []
|
||||
for (const item of items || []) {
|
||||
const currentParents = item?.title ? [...parents, item.title] : parents
|
||||
if (item?.subItems?.length) {
|
||||
results.push(...buildSearchResults(item.subItems, keyword, currentParents))
|
||||
}
|
||||
if (item?.path && item?.title) {
|
||||
const hit = item.title.toLowerCase().includes(keyword)
|
||||
if (hit) {
|
||||
results.push({
|
||||
title: item.title,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
parents: parents,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
|
||||
function handleSearch(value) {
|
||||
const keyword = String(value ?? '').trim()
|
||||
searchKeyword.value = keyword
|
||||
if (!keyword) {
|
||||
// 空字串時不顯示結果彈窗
|
||||
searchResults.value = []
|
||||
searchDialog.value = false
|
||||
return
|
||||
}
|
||||
const lowered = keyword.toLowerCase()
|
||||
// 依合併後的 menuItems 進行比對
|
||||
searchResults.value = buildSearchResults(mergedMenuItems.value, lowered)
|
||||
// 開啟彈窗顯示搜尋結果
|
||||
searchDialog.value = true
|
||||
}
|
||||
|
||||
// 點擊搜尋結果後導頁(行為等同選單點擊)
|
||||
function handleSearchSelect(item) {
|
||||
searchDialog.value = false
|
||||
handleSelect(item)
|
||||
}
|
||||
|
||||
// 訊息中心的示意資料,僅用於展示列表,不進行 API 呼叫
|
||||
const messageItems = [
|
||||
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: mdiBellOutline },
|
||||
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: mdiSchoolOutline },
|
||||
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: mdiHomeCityOutline },
|
||||
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: mdiCalendarOutline },
|
||||
]
|
||||
|
||||
// v-data-iterator 會包裝 items,這裡取回原始資料物件
|
||||
function resolveMessageItem(wrapped) {
|
||||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||
return wrapped.raw
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// 由 layout 的 action 事件統一進入此處處理
|
||||
// 目前只處理訊息中心,其他 action 可在此擴充
|
||||
function handleLayoutAction(type) {
|
||||
if (type === 'messages') {
|
||||
messageStore.open()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function performLogout({ message, color }) {
|
||||
authStore.logout()
|
||||
tabs.value = []
|
||||
activeTab.value = null
|
||||
snackbar.show({ message, color })
|
||||
router.replace({ name: 'login' })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
performLogout({ message: '登出成功', color: 'success' })
|
||||
}
|
||||
|
||||
function handleForceLogout(event) {
|
||||
const message = event?.detail?.message || '請重新登入'
|
||||
performLogout({ message, color: 'warning' })
|
||||
}
|
||||
|
||||
function handleHttpToast(event) {
|
||||
const detail = event?.detail
|
||||
const message = detail?.message
|
||||
if (!message) return
|
||||
|
||||
const level = detail?.level
|
||||
const color = level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'
|
||||
snackbar.show({ message, color, timeout: 3000, location: 'top right', variant: 'flat' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
||||
window.addEventListener(HTTP_TOAST_EVENT, handleHttpToast)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
||||
window.removeEventListener(HTTP_TOAST_EVENT, handleHttpToast)
|
||||
})
|
||||
|
||||
// --- Tabs Logic ---
|
||||
|
||||
const tabs = ref([])
|
||||
const activeTab = ref(null)
|
||||
|
||||
const showTabs = computed(() => {
|
||||
return route.meta.layout === 'default'
|
||||
})
|
||||
|
||||
// 遞迴尋找標題
|
||||
function findTitle(path) {
|
||||
const recursiveFind = (items) => {
|
||||
for (const item of items) {
|
||||
if (item.path === path) return item.title
|
||||
if (item.subItems?.length) {
|
||||
const found = recursiveFind(item.subItems)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 1. 搜尋 Store 中的選單
|
||||
let title = recursiveFind(menuStore.menuItems)
|
||||
if (title) return title
|
||||
|
||||
// 2. 搜尋最愛選單
|
||||
title = recursiveFind(menuStore.favoriteItems)
|
||||
if (title) return title
|
||||
|
||||
// 3. 搜尋靜態範例選單
|
||||
title = recursiveFind(_menuItemsExample)
|
||||
if (title) return title
|
||||
|
||||
// 4. 特殊路徑處理
|
||||
if (path === '/') return '首頁'
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function findMenuItem(path) {
|
||||
const recursiveFind = (items) => {
|
||||
for (const item of items) {
|
||||
if (item.path === path) return item
|
||||
if (item.subItems?.length) {
|
||||
const found = recursiveFind(item.subItems)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return recursiveFind(mergedMenuItems.value)
|
||||
}
|
||||
|
||||
const currentFavoriteInfo = computed(() => {
|
||||
const path = route.path
|
||||
const menuItem = findMenuItem(path)
|
||||
const title =
|
||||
menuItem?.title ||
|
||||
(typeof route.meta?.title === 'string' ? route.meta.title : null) ||
|
||||
findTitle(path)
|
||||
return {
|
||||
title,
|
||||
path,
|
||||
icon: menuItem?.icon,
|
||||
}
|
||||
})
|
||||
|
||||
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
|
||||
const isFavoriteActionDisabled = computed(
|
||||
() => !currentFavoriteInfo.value?.path || route.path === '/'
|
||||
)
|
||||
|
||||
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
|
||||
const favoriteActionIcon = computed(() =>
|
||||
isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle
|
||||
)
|
||||
|
||||
function toggleFavoriteItem(item) {
|
||||
if (!item?.path || item.path === '/') return
|
||||
favoritesStore.toggle({
|
||||
title: item.title || findTitle(item.path),
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleFavorite() {
|
||||
toggleFavoriteItem(currentFavoriteInfo.value)
|
||||
}
|
||||
|
||||
function handleRemoveFavorite(item) {
|
||||
toggleFavoriteItem(item)
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function updateBreadcrumbs() {
|
||||
const resolvedTitle = findTitle(route.path)
|
||||
const fallbackTitle =
|
||||
resolvedTitle && resolvedTitle !== route.path
|
||||
? resolvedTitle
|
||||
: typeof route.meta?.title === 'string'
|
||||
? route.meta.title
|
||||
: null
|
||||
|
||||
breadcrumbStore.setBreadcrumbs({
|
||||
path: route.path,
|
||||
menuItems: mergedMenuItems.value,
|
||||
favoriteItems: mergedFavoriteItems.value,
|
||||
fallbackTitle,
|
||||
homeLabel: '首頁',
|
||||
homeIcon: mdiHome,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => route.path,
|
||||
() => menuStore.menuItems,
|
||||
() => menuStore.favoriteItems,
|
||||
() => favoritesStore.items,
|
||||
],
|
||||
() => updateBreadcrumbs(),
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 監聽路由變化,新增 Tab
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
if (!showTabs.value) return
|
||||
|
||||
const existingTab = tabs.value.find((t) => t.path === newPath)
|
||||
if (!existingTab) {
|
||||
const title = findTitle(newPath)
|
||||
tabs.value.push({ title, path: newPath })
|
||||
}
|
||||
activeTab.value = newPath
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function closeTab(path) {
|
||||
if (tabs.value.length <= 1) return
|
||||
|
||||
const index = tabs.value.findIndex((t) => t.path === path)
|
||||
if (index === -1) return
|
||||
|
||||
tabs.value.splice(index, 1)
|
||||
|
||||
// 如果關閉的是當前分頁,則跳轉到其他分頁
|
||||
if (route.path === path) {
|
||||
const nextTab = tabs.value[index] || tabs.value[index - 1]
|
||||
if (nextTab) {
|
||||
router.push(nextTab.path)
|
||||
} else {
|
||||
// 若無剩餘分頁,回到首頁
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import AppShell from '@/shell/AppShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Src Guide
|
||||
|
||||
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view 與 composable 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
|
||||
|
||||
## 資料流
|
||||
|
||||
```txt
|
||||
router -> AppShell -> layout -> view -> Section -> Item
|
||||
↓
|
||||
composable -> store -> service
|
||||
```
|
||||
|
||||
## 主要目錄
|
||||
|
||||
- `views/`:route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`。
|
||||
- `components/`:Vue UI 元件,依 sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`。
|
||||
- `composables/`:page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`。
|
||||
- `router/`:route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`。
|
||||
- `shell/`:AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`。
|
||||
- `stores/`:跨頁共享狀態與快取。詳見 `src/stores/GUIDE.md`。
|
||||
- `services/`:HTTP client、API module、token/session、錯誤處理。詳見 `src/services/GUIDE.md`。
|
||||
- `language/`:Vue I18n 文案。詳見 `src/language/GUIDE.md`。
|
||||
|
||||
## Template Core
|
||||
|
||||
一般功能需求預設不修改:
|
||||
|
||||
- `main.ts`
|
||||
- `App.vue`
|
||||
- `shell/*`
|
||||
- `components/layouts/*`
|
||||
- `views/Login.vue`
|
||||
- `router/index.ts`
|
||||
- `router/guards.ts`
|
||||
- `plugins/*`
|
||||
- `styles/*`
|
||||
- `services/client.ts`
|
||||
- `services/interceptors.ts`
|
||||
- `services/token.ts`
|
||||
- `services/session.ts`
|
||||
- `stores/auth.ts`
|
||||
- `stores/menu.ts`
|
||||
- `stores/breadcrumbs.ts`
|
||||
- `stores/favorites.ts`
|
||||
- `stores/messages.ts`
|
||||
- `stores/snackbar.ts`
|
||||
- `stores/app.ts`
|
||||
- `composables/layout/*`
|
||||
|
||||
只有需求明確要求調整 template shell、登入、router guard、HTTP core 或全域狀態時才修改上述檔案。
|
||||
|
||||
## Demo / Example
|
||||
|
||||
下列檔案偏向示範功能,正式專案可依需求替換或移除:
|
||||
|
||||
- `views/Home.vue`
|
||||
- `views/FncPage.vue`
|
||||
- `views/Settings.vue`
|
||||
- `views/maint/*`
|
||||
- `components/maint/MaintShell.vue`
|
||||
- `components/maint/*`
|
||||
- `components/sections/*`
|
||||
- `components/items/*`
|
||||
- `composables/page-drivers/*MaintenancePage.ts`
|
||||
- `composables/maint/*`
|
||||
- `composables/useCrudCommands.ts`
|
||||
- `stores/students.ts`
|
||||
- `stores/semesters.ts`
|
||||
- demo assets 與 demo language keys
|
||||
|
||||
移除 demo 時,同步清理 route、menu/favorites/breadcrumb 流程、語系文案與不再使用的 import。
|
||||
|
||||
## 新功能流程
|
||||
|
||||
1. 新增或修改 `views/*` route entry,直接在 view 裡組裝 page model 與 UI。
|
||||
2. 若有複雜的資料協調(多 composable、搜尋狀態、CRUD flow、dialog 狀態),新增 `composables/page-drivers/useXxxPage.ts`。簡單頁面直接在 view 用 `computed` 組裝。
|
||||
3. 若畫面有獨立區塊,拆到 `components/sections/*`。
|
||||
4. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`。
|
||||
5. 跨頁共享狀態才新增或修改 `stores/*`。
|
||||
6. 外部 API 放在 `services/modules/*`。
|
||||
7. 在 `router/routes.ts` 新增 route。
|
||||
|
||||
## 驗證
|
||||
|
||||
- Vue / TypeScript 結構變更:`pnpm -s type-check`
|
||||
- 需要確認產物:`pnpm -s build`
|
||||
- route、layout 或主要畫面流程變更:啟動 dev server 並做瀏覽器檢查,除非使用者明確不需要。
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# Src 開發入口
|
||||
|
||||
`src` 是 template 使用者主要修改的區域。新增功能時,先從 `views` 與 `router/routes.ts` 開始,除非需求明確牽涉 app shell,否則不要先改 layout。
|
||||
|
||||
## 常見開發流程
|
||||
|
||||
1. 在 `src/views` 或 `src/views/<feature>` 新增 route view。
|
||||
2. 在 `src/router/routes.ts` 加入 route。
|
||||
3. 一般頁面使用 `meta: { layout: 'default' }`,讓內容被 `MainLayout` 包住。
|
||||
4. 畫面超過單一簡單區塊時,拆到 `src/components/<feature>`。
|
||||
5. 可重用流程或較複雜 UI state 放到 `src/composables/<feature>`。
|
||||
6. 跨頁共享狀態放到 `src/stores/*.ts`。
|
||||
7. API 呼叫放到 `src/services/modules/<domain>.ts`。
|
||||
|
||||
## 主要資料流
|
||||
|
||||
```txt
|
||||
router -> App.vue -> layout -> view -> component -> composable/store -> service
|
||||
```
|
||||
|
||||
責任分工:
|
||||
|
||||
- `router`:route、layout meta、auth meta 與錯誤頁入口。
|
||||
- `App.vue`:根據 route meta 組裝 layout、全域 UI 與 layout event。
|
||||
- `views`:路由入口、頁面資料協調與頁面事件協調。
|
||||
- `components`:畫面呈現、props/emits 與可拆分 UI 區塊。
|
||||
- `composables`:可重用流程、頁面狀態機或較複雜 UI state。
|
||||
- `stores`:跨頁共享狀態、快取與全域顯示狀態。
|
||||
- `services`:HTTP client、API 模組、token、session 與錯誤處理。
|
||||
|
||||
## Template Core
|
||||
|
||||
一般功能開發時,優先視為 template core:
|
||||
|
||||
App shell:
|
||||
|
||||
- `src/main.ts`
|
||||
- `src/App.vue`
|
||||
|
||||
Layout:
|
||||
|
||||
- `src/components/layouts/MainLayout.vue`
|
||||
- `src/components/layouts/PlainLayout.vue`
|
||||
- `src/components/layouts/main-layout/*`
|
||||
|
||||
Login entry:
|
||||
|
||||
- `src/views/Login.vue`
|
||||
|
||||
Router core:
|
||||
|
||||
- `src/router/index.ts`
|
||||
- `src/router/guards.ts`
|
||||
|
||||
Plugin / theme core:
|
||||
|
||||
- `src/plugins/*`
|
||||
- `src/styles/*`
|
||||
|
||||
HTTP core:
|
||||
|
||||
- `src/services/client.ts`
|
||||
- `src/services/interceptors.ts`
|
||||
- `src/services/token.ts`
|
||||
- `src/services/session.ts`
|
||||
- `src/services/error.ts`
|
||||
- `src/services/http-error.ts`
|
||||
- `src/services/http-toast.ts`
|
||||
|
||||
Global stores:
|
||||
|
||||
- `src/stores/auth.ts`
|
||||
- `src/stores/menu.ts`
|
||||
- `src/stores/breadcrumbs.ts`
|
||||
- `src/stores/favorites.ts`
|
||||
- `src/stores/messages.ts`
|
||||
- `src/stores/snackbar.ts`
|
||||
|
||||
Layout composables:
|
||||
|
||||
- `src/composables/layout/*`
|
||||
|
||||
這些檔案支撐 app shell、登入、路由、全域狀態與 API 基礎設施。只有需求明確要求修改 template core 時才調整。
|
||||
|
||||
`src/router/routes.ts` 是功能開發時可新增 route 的入口,但不要改壞既有 layout meta、auth meta 與 catch-all route 規則。
|
||||
|
||||
登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/views/Login.vue` 與 `src/components/login/*`。
|
||||
|
||||
`src/services/modules/auth.ts` 與 `src/services/modules/menu.ts` 是預設後端契約的 API adapter。接新後端時常需要調整,但不要把 UI 狀態放進 service module。
|
||||
|
||||
## Demo / Example
|
||||
|
||||
以下內容偏向示範資料與範例頁,建立新專案時可依需求替換或刪除:
|
||||
|
||||
- `src/views/Home.vue`
|
||||
|
||||
- `src/views/maint/*`
|
||||
- `src/components/maint/*`
|
||||
- `src/composables/maint/*`
|
||||
- `src/components/maint/MaintShell.vue`
|
||||
- `src/stores/students.ts`
|
||||
- `src/stores/semesters.ts`
|
||||
- `src/views/FncPage.vue`
|
||||
- `src/views/Settings.vue`
|
||||
- `src/language/*.json` 中與 starter home、maint、學生資料相關的文案
|
||||
- `src/assets/logo.png`
|
||||
- `src/assets/logo.svg`
|
||||
- `src/assets/robot-svgrepo-com.svg`
|
||||
|
||||
刪除 demo/example 時,也要同步清理:
|
||||
|
||||
- `src/router/routes.ts`
|
||||
- `src/stores/menu.ts` 中依賴的選單資料流程
|
||||
- 相關 `src/language/*.json` 文案
|
||||
- 不再被 import 的 demo components、composables、stores 與 assets
|
||||
|
||||
## 新頁面最小範例
|
||||
|
||||
```ts
|
||||
// src/router/routes.ts
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'reports',
|
||||
component: () => import('@/views/reports/Reports.vue'),
|
||||
meta: { layout: 'default', requiresAuth: true },
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- src/views/reports/Reports.vue -->
|
||||
<script setup lang="ts">
|
||||
import ReportsTable from '@/components/reports/ReportsTable.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportsTable />
|
||||
</template>
|
||||
```
|
||||
|
||||
更多範例見 `docs/add-page-example.md`。
|
||||
|
||||
## 文件導覽
|
||||
|
||||
- `docs/add-page-example.md`:新增頁面範例。
|
||||
- `docs/frontend-layering.md`:完整分層與責任邊界。
|
||||
- `docs/llm-development-guide.md`:LLM 操作邊界。
|
||||
|
||||
## 驗證
|
||||
|
||||
```bash
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Components Guide
|
||||
|
||||
`components` 放 Vue UI 元件,依 sections / items / layouts / base 分層。頁面不再有獨立的 page component 層 — 頁面 UI 直接寫在 `views/*` 中。
|
||||
|
||||
## 子目錄
|
||||
|
||||
- `sections/`:獨立畫面區塊(搜尋面板、資料表格、表單面板),決定佈局,不關心單筆內容。詳見 `src/components/sections/GUIDE.md`。
|
||||
- `items/`:單一資料單位的純粹呈現,不管理狀態。詳見 `src/components/items/GUIDE.md`。
|
||||
- `layouts/`:App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md`。
|
||||
- `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md`。
|
||||
|
||||
`MaintShell.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/maint/`。
|
||||
|
||||
## 規則
|
||||
|
||||
- 元件不直接 import store 或 service。
|
||||
- 元件以 props 接收資料,以 emits 回報使用者意圖。
|
||||
- 可複用元件不含 domain 名稱(如 `student`、`course`)。
|
||||
|
||||
## 驗證
|
||||
|
||||
- Vue / TypeScript 結構變更:`pnpm -s type-check`
|
||||
- 需要確認產物:`pnpm -s build`
|
||||
- route、layout 或主要畫面流程變更:啟動 dev server 並做瀏覽器檢查,除非使用者明確不需要。
|
||||
@@ -1,234 +0,0 @@
|
||||
<template>
|
||||
<v-container class="pa-0" fluid>
|
||||
<div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
|
||||
<v-sheet
|
||||
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
|
||||
color="surface"
|
||||
>
|
||||
<v-avatar color="primary" size="52" variant="tonal">
|
||||
<span class="text-h5">👋</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
使用頂部搜尋框快速找到功能,或從左側選單瀏覽所有系統模組
|
||||
</div>
|
||||
</div>
|
||||
</v-sheet>
|
||||
|
||||
<section class="d-flex flex-column">
|
||||
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
|
||||
<!--
|
||||
使用 v-data-iterator 保留一致的列表輸出結構,
|
||||
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架。
|
||||
-->
|
||||
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
|
||||
<!--
|
||||
Vuetify 會把原始資料包進 wrapper,
|
||||
這裡統一解包可避免模板層散落型別判斷。
|
||||
-->
|
||||
<template #default="{ items }">
|
||||
<v-row density="compact">
|
||||
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
|
||||
<v-card
|
||||
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
|
||||
variant="outlined"
|
||||
@click="emit('news', resolveNewsItem(wrapped))"
|
||||
>
|
||||
<v-sheet class="news-badge">
|
||||
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
|
||||
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
|
||||
</v-sheet>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex flex-wrap align-center font-weight-bold">
|
||||
{{ resolveNewsItem(wrapped).title }}
|
||||
<v-chip
|
||||
v-if="resolveNewsItem(wrapped).isNew"
|
||||
class="ml-2"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
>
|
||||
NEW
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-2">
|
||||
{{ resolveNewsItem(wrapped).desc }}
|
||||
</div>
|
||||
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon size="14" :icon="mdiFolderOutline" />
|
||||
<span>{{ resolveNewsItem(wrapped).dept }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon size="14" :icon="mdiEyeOutline" />
|
||||
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-iterator>
|
||||
</section>
|
||||
|
||||
<v-card
|
||||
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
|
||||
color="secondary"
|
||||
rounded="xl"
|
||||
variant="tonal"
|
||||
@click="emit('message-center')"
|
||||
>
|
||||
<div class="d-flex align-center ga-4">
|
||||
<v-avatar color="secondary" size="44" variant="flat">
|
||||
<span class="text-h6">✉️</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
|
||||
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-body-2 font-weight-medium">查看全部 →</div>
|
||||
</v-card>
|
||||
|
||||
<section class="d-flex flex-column pb-4">
|
||||
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
|
||||
<v-row class="mt-2" density="compact">
|
||||
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
|
||||
<v-card
|
||||
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
|
||||
variant="outlined"
|
||||
@click="emit('quick', item)"
|
||||
>
|
||||
<div class="text-h5">{{ item.icon }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
這個 dialog 只做消息內容呈現,
|
||||
開關狀態仍交給 view 管理,避免頁面元件自行持有流程狀態。
|
||||
-->
|
||||
<v-dialog
|
||||
:model-value="props.isNewsDialogOpen"
|
||||
max-width="640"
|
||||
@update:model-value="emit('update:isNewsDialogOpen', $event)"
|
||||
>
|
||||
<v-card v-if="props.selectedNews">
|
||||
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
|
||||
{{ props.selectedNews.title }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
|
||||
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
|
||||
{{ props.selectedNews.dept }} · {{ props.selectedNews.views }} 次瀏覽
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="pt-4">
|
||||
{{ props.selectedNews.desc }}
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
|
||||
關閉
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
|
||||
|
||||
interface NewsItem {
|
||||
id: number
|
||||
date: string
|
||||
month: string
|
||||
title: string
|
||||
desc: string
|
||||
dept: string
|
||||
views: string
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
interface QuickItem {
|
||||
icon: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
newsItems: NewsItem[]
|
||||
quickItems: QuickItem[]
|
||||
selectedNews: NewsItem | null
|
||||
isNewsDialogOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
news: [item: NewsItem]
|
||||
'message-center': []
|
||||
quick: [item: QuickItem]
|
||||
'update:isNewsDialogOpen': [value: boolean]
|
||||
}>()
|
||||
|
||||
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
|
||||
function resolveNewsItem(wrapped: unknown): NewsItem {
|
||||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||
return (wrapped as { raw: NewsItem }).raw
|
||||
}
|
||||
|
||||
return wrapped as NewsItem
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.news-item {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.news-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
|
||||
.news-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
min-height: 64px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.news-badge-date {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.news-badge-month {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -1,532 +0,0 @@
|
||||
<template>
|
||||
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
||||
<!-- Side Layouts -->
|
||||
<v-row
|
||||
v-if="props.layout !== 'card'"
|
||||
class="fill-height"
|
||||
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
||||
no-gutters
|
||||
>
|
||||
<!-- Illustration Column -->
|
||||
<v-col
|
||||
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
|
||||
cols="12"
|
||||
lg="8"
|
||||
sm="6"
|
||||
>
|
||||
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<v-sheet
|
||||
class="board-wrapper pa-2 pa-lg-0"
|
||||
color="rgba(var(--v-theme-surface), 0.8)"
|
||||
elevation="0"
|
||||
max-width="680"
|
||||
rounded="lg"
|
||||
width="100%"
|
||||
>
|
||||
<LoginAnnouncementBoard
|
||||
:all-tab-label="props.announcementBoard.allTabLabel"
|
||||
:date-header="props.announcementBoard.dateHeader"
|
||||
:empty-text="props.announcementBoard.emptyText"
|
||||
:items="props.announcementBoard.items"
|
||||
:items-per-page="props.announcementBoard.itemsPerPage"
|
||||
:pagination-label="props.announcementBoard.paginationLabel"
|
||||
:school-header="props.announcementBoard.schoolHeader"
|
||||
:system-announcements="props.announcementBoard.systemAnnouncements"
|
||||
:tabs="props.announcementBoard.tabs"
|
||||
:title="props.announcementBoard.title"
|
||||
:title-header="props.announcementBoard.titleHeader"
|
||||
@select-announcement="handleSelectAnnouncement"
|
||||
/>
|
||||
</v-sheet>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
|
||||
cols="12"
|
||||
lg="4"
|
||||
sm="6"
|
||||
>
|
||||
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
|
||||
<v-banner
|
||||
class="d-sm-none mb-2"
|
||||
density="comfortable"
|
||||
lines="one"
|
||||
:mobile="false"
|
||||
:stacked="false"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-slide-x-transition appear>
|
||||
<div class="mobile-banner-icon-wrap d-flex align-center">
|
||||
<v-icon
|
||||
class="mobile-banner-icon"
|
||||
color="primary"
|
||||
size="small"
|
||||
:icon="mdiBullhornVariantOutline"
|
||||
/>
|
||||
</div>
|
||||
</v-slide-x-transition>
|
||||
</template>
|
||||
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
||||
<template #actions>
|
||||
<v-btn
|
||||
class="text-none"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="mobileAnnouncementSheetVisible = true"
|
||||
>
|
||||
{{ props.mobileAnnouncement.viewAllText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-banner>
|
||||
</div>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div
|
||||
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
|
||||
>
|
||||
<div class="login-header-height d-flex d-sm-none justify-center align-start">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-sm-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
|
||||
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Card Layout (Centered) -->
|
||||
<v-row
|
||||
v-else
|
||||
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
|
||||
no-gutters
|
||||
>
|
||||
<v-card
|
||||
class="rounded-lg"
|
||||
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
|
||||
elevation="10"
|
||||
max-width="450"
|
||||
width="100%"
|
||||
>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div class="d-flex justify-center mb-6 mb-md-4">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-md-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-8 text-center text-caption text-grey-darken-2">
|
||||
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-row>
|
||||
|
||||
<v-bottom-sheet v-model="mobileAnnouncementSheetVisible" class="d-sm-none">
|
||||
<v-card rounded="t-xl">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold">
|
||||
{{ props.mobileAnnouncement.listTitle }}
|
||||
</v-card-title>
|
||||
<v-list lines="two">
|
||||
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
|
||||
<v-list-item-title>{{ item.content }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
||||
{{ item.title }}<span v-if="item.title && item.createdAt"> ・ </span
|
||||
>{{ item.createdAt }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
||||
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
|
||||
{{ props.mobileAnnouncement.closeText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-bottom-sheet>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiBullhornVariantOutline } from '@mdi/js'
|
||||
import { computed, ref } from 'vue'
|
||||
import LoginAnnouncementBoard from './login/LoginAnnouncementBoard.vue'
|
||||
import LoginBrand from './login/LoginBrand.vue'
|
||||
import LoginForm from './login/LoginForm.vue'
|
||||
import LoginHeader from './login/LoginHeader.vue'
|
||||
import LoginToolBar from './login/LoginToolBar.vue'
|
||||
import LoginVerify from './login/LoginVerify.vue'
|
||||
|
||||
interface BrandingConfig {
|
||||
title?: string
|
||||
organization?: string
|
||||
}
|
||||
|
||||
interface IllustrationConfig {
|
||||
image?: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface HeaderConfig {
|
||||
welcomeText?: string
|
||||
welcomeDescription?: string
|
||||
}
|
||||
|
||||
interface AnnouncementTabConfig {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AnnouncementItemConfig {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
}
|
||||
|
||||
interface AnnouncementBoardConfig {
|
||||
title?: string
|
||||
tabs?: AnnouncementTabConfig[]
|
||||
items?: AnnouncementItemConfig[]
|
||||
systemAnnouncements?: {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}[]
|
||||
allTabLabel?: string
|
||||
itemsPerPage?: number
|
||||
dateHeader?: string
|
||||
schoolHeader?: string
|
||||
titleHeader?: string
|
||||
emptyText?: string
|
||||
paginationLabel?: string
|
||||
}
|
||||
|
||||
interface MobileAnnouncementConfig {
|
||||
items?: {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}[]
|
||||
show?: boolean
|
||||
viewAllText?: string
|
||||
listTitle?: string
|
||||
closeText?: string
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
interface ForgotPasswordConfig {
|
||||
text?: string
|
||||
href?: string
|
||||
target?: string
|
||||
}
|
||||
|
||||
interface FormConfig {
|
||||
accPlaceholder?: string
|
||||
passwPlaceholder?: string
|
||||
rememberMeLabel?: string
|
||||
submitText?: string
|
||||
rememberStorageKey?: string
|
||||
withCaptcha?: boolean
|
||||
captcha?: {
|
||||
imgUrl?: string
|
||||
id?: string
|
||||
tokenValue?: string
|
||||
}
|
||||
captchaValue?: string
|
||||
captchaLoading?: boolean
|
||||
captchaErrorMessage?: string
|
||||
captchaVerified?: boolean
|
||||
verifyText?: string
|
||||
captchaPlaceholder?: string
|
||||
refreshTitle?: string
|
||||
forgotPassword: ForgotPasswordConfig
|
||||
}
|
||||
|
||||
interface ToolBarConfig {
|
||||
show?: boolean
|
||||
locale?: string
|
||||
locales?: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
layout: 'side-left' | 'side-right' | 'card'
|
||||
branding: BrandingConfig
|
||||
illustration: IllustrationConfig
|
||||
announcementBoard: AnnouncementBoardConfig
|
||||
mobileAnnouncement: MobileAnnouncementConfig
|
||||
header: HeaderConfig
|
||||
form: FormConfig
|
||||
toolbar: ToolBarConfig
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'side-left',
|
||||
branding: () => ({
|
||||
title: 'Skyteck Login',
|
||||
organization: 'school',
|
||||
}),
|
||||
illustration: () => ({
|
||||
image: null,
|
||||
title: 'Login',
|
||||
description: 'Login to your account',
|
||||
}),
|
||||
announcementBoard: () => ({
|
||||
title: '學校公告區',
|
||||
tabs: [
|
||||
{ label: '全部', value: '__all__' },
|
||||
{ label: '國中', value: 'junior' },
|
||||
{ label: '高中', value: 'senior' },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'announcement-1',
|
||||
date: '2024-03-19',
|
||||
school: '市立實踐國中',
|
||||
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
|
||||
tab: 'junior',
|
||||
},
|
||||
],
|
||||
systemAnnouncements: [],
|
||||
allTabLabel: '全部',
|
||||
itemsPerPage: 5,
|
||||
dateHeader: '公告時間',
|
||||
schoolHeader: '公告學校',
|
||||
titleHeader: '公告標題',
|
||||
emptyText: '目前沒有公告資料',
|
||||
paginationLabel: '總筆數:',
|
||||
}),
|
||||
mobileAnnouncement: () => ({
|
||||
items: [],
|
||||
show: false,
|
||||
viewAllText: '查看全部',
|
||||
listTitle: '系統公告',
|
||||
closeText: '關閉',
|
||||
emptyText: '目前沒有公告',
|
||||
}),
|
||||
header: () => ({
|
||||
welcomeText: 'Welcome back 👋🏻',
|
||||
welcomeDescription: 'Please enter your account password to login',
|
||||
}),
|
||||
form: () => ({
|
||||
accPlaceholder: '請輸入帳號',
|
||||
passwPlaceholder: '請輸入密碼',
|
||||
rememberMeLabel: '記住帳號',
|
||||
submitText: '登入',
|
||||
rememberStorageKey: 'sklogin.remember.username',
|
||||
withCaptcha: true,
|
||||
captcha: undefined,
|
||||
captchaValue: '',
|
||||
captchaLoading: false,
|
||||
captchaErrorMessage: '',
|
||||
captchaVerified: false,
|
||||
verifyText: '驗證',
|
||||
captchaPlaceholder: '驗證碼',
|
||||
refreshTitle: '點擊刷新驗證碼',
|
||||
forgotPassword: {
|
||||
text: '忘記密碼?',
|
||||
href: '',
|
||||
target: undefined,
|
||||
},
|
||||
}),
|
||||
toolbar: () => ({
|
||||
show: true,
|
||||
locale: 'zh-TW',
|
||||
locales: ['zh-TW', 'en-US'],
|
||||
}),
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'submit',
|
||||
'change-locale',
|
||||
'forgot-password',
|
||||
'captcha-refresh',
|
||||
'captcha-change',
|
||||
'toggle-layout',
|
||||
'select-announcement',
|
||||
])
|
||||
|
||||
const mobileAnnouncementSheetVisible = ref(false)
|
||||
|
||||
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
|
||||
|
||||
const showMobileAnnouncementBanner = computed(() => {
|
||||
if (props.mobileAnnouncement.show === false) return false
|
||||
return mobileAnnouncementItems.value.length > 0
|
||||
})
|
||||
|
||||
const mobileAnnouncementBannerText = computed(() => {
|
||||
return mobileAnnouncementItems.value[0]?.content ?? ''
|
||||
})
|
||||
|
||||
const layoutClass = computed(() => {
|
||||
return `layout-${props.layout}`
|
||||
})
|
||||
|
||||
function handleLogin(formData: Record<string, unknown>) {
|
||||
emit('submit', formData)
|
||||
}
|
||||
|
||||
function handleCaptchaRefresh() {
|
||||
emit('captcha-refresh')
|
||||
}
|
||||
|
||||
function handleCaptchaChange(value: string) {
|
||||
emit('captcha-change', value)
|
||||
}
|
||||
|
||||
function handleChangeLocale(nextLocale: string) {
|
||||
emit('change-locale', nextLocale)
|
||||
}
|
||||
|
||||
function handleToggleLayout() {
|
||||
emit('toggle-layout')
|
||||
}
|
||||
|
||||
function handleForgotPassword(e: MouseEvent) {
|
||||
emit('forgot-password', e)
|
||||
}
|
||||
|
||||
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
|
||||
emit('select-announcement', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.v-banner__prepend) {
|
||||
align-self: center;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
:deep(.v-banner-actions) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.mobile-banner-icon {
|
||||
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes mobile-banner-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-banner-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.illustration-panel {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--v-theme-background)) 0%,
|
||||
rgb(var(--v-theme-surface)) 100%
|
||||
);
|
||||
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
max-width: 450px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-header-height {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
/* Specific styles for side-right to flip border */
|
||||
.layout-side-right .illustration-panel {
|
||||
border-right: none;
|
||||
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
# Components
|
||||
|
||||
Vue template files in this folder are automatically imported.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
|
||||
|
||||
The following example assumes a component located at `src/components/MyComponent.vue`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<MyComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
```
|
||||
|
||||
When your template is rendered, the component's import will automatically be inlined, which renders to this:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<MyComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MyComponent from '@/components/MyComponent.vue'
|
||||
</script>
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
items: any[]
|
||||
labelCharCount?: number
|
||||
prependMarginEnd?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<any>({ required: true })
|
||||
|
||||
const prependMinWidth = computed(() =>
|
||||
props.labelCharCount != null ? `${props.labelCharCount * 0.785}rem` : undefined,
|
||||
)
|
||||
|
||||
const marginEndStyle = computed(() => `${props.prependMarginEnd ?? 8}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-select v-model="modelValue" variant="outlined" density="compact" hide-details :items="items">
|
||||
<template v-if="label" #prepend>
|
||||
<span
|
||||
class="text-title-small"
|
||||
:style="prependMinWidth ? { minWidth: prependMinWidth } : undefined"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.v-input__prepend) {
|
||||
margin-inline-end: v-bind(marginEndStyle);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user