feat: mirror v-can guard chains
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-11-15
|
||||||
|
### Added
|
||||||
|
- Automatically mirror `v-can` guards across `v-else-if` / `v-else` branches and surface the inferred expression in documentation and fixtures.
|
||||||
|
- New `chains` playground fixture plus SSR tests that cover guard mirroring and explicit `v-cannot` fallbacks.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Enforce adjacency rules for implicit `v-cannot` blocks while allowing wrapped fallbacks when the permission expression is provided.
|
||||||
|
- Expand README guidance with explicit examples for guard mirroring, wrapped fallbacks, and known DX errors.
|
||||||
|
|
||||||
## [1.0.1] - 2025-11-14
|
## [1.0.1] - 2025-11-14
|
||||||
### Changed
|
### Changed
|
||||||
- Publish as the scoped package `@eduvia-app/nuxt-can` and document the scoped install steps.
|
- Publish as the scoped package `@eduvia-app/nuxt-can` and document the scoped install steps.
|
||||||
@@ -26,5 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- English README describing usage, playground, and contribution guide.
|
- English README describing usage, playground, and contribution guide.
|
||||||
- Roadmap and release prep guidance.
|
- Roadmap and release prep guidance.
|
||||||
|
|
||||||
|
[1.1.0]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.1.0
|
||||||
[1.0.1]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.0.1
|
[1.0.1]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.0.1
|
||||||
[1.0.0]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.0.0
|
[1.0.0]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.0.0
|
||||||
|
|||||||
90
README.md
90
README.md
@@ -52,7 +52,7 @@ Provide the `__can__` implementation referenced above:
|
|||||||
// permissions/can.ts
|
// permissions/can.ts
|
||||||
const permissionsStore = usePermissionsStore()
|
const permissionsStore = usePermissionsStore()
|
||||||
|
|
||||||
export function __can__(path: string[]) {
|
export function __can__(...path: string[]) {
|
||||||
return permissionsStore.check(path.join('.'))
|
return permissionsStore.check(path.join('.'))
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -72,18 +72,94 @@ Now you can write directives that stay type-safe:
|
|||||||
…and the compiler rewrites them into plain conditionals:
|
…and the compiler rewrites them into plain conditionals:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<button v-if="__can__(['employee', 'view'])">View profile</button>
|
<button v-if="__can__('employee', 'view')">View profile</button>
|
||||||
<button v-if="(isReady) && __can__(['employee', 'edit'])">Edit profile</button>
|
<button v-if="(isReady) && __can__('employee', 'edit')">Edit profile</button>
|
||||||
<p v-if="!(__can__(['employee', 'edit']))">Access denied</p>
|
<p v-if="!(__can__('employee', 'edit'))">Access denied</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directive Patterns
|
||||||
|
|
||||||
|
### Guard entire `v-if` / `v-else-if` / `v-else` chains
|
||||||
|
|
||||||
|
Once the first branch of a conditional chain carries `v-can`, the transformer automatically mirrors that guard (with the same permission path) on every subsequent `v-else-if` and `v-else`. You can still repeat the directive manually for clarity, but it’s no longer required.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="status === 'draft'" v-can="can.foo.bar">
|
||||||
|
Draft state
|
||||||
|
</div>
|
||||||
|
<div v-else-if="status === 'pending'">
|
||||||
|
Pending state
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
Fallback state
|
||||||
|
</div>
|
||||||
|
<div v-cannot="can.foo.bar">
|
||||||
|
Missing permission
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Transforms into:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="(status === 'draft') && __can__('foo', 'bar')">
|
||||||
|
Draft state
|
||||||
|
</div>
|
||||||
|
<div v-else-if="(status === 'pending') && __can__('foo', 'bar')">
|
||||||
|
Pending state
|
||||||
|
</div>
|
||||||
|
<div v-else-if="__can__('foo', 'bar')">
|
||||||
|
Fallback state
|
||||||
|
</div>
|
||||||
|
<div v-if="!__can__('foo', 'bar')">
|
||||||
|
Missing permission
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pass arguments to `v-cannot`
|
||||||
|
|
||||||
|
`v-cannot` can mirror the permission expression used by its matching `v-can` by adding the same argument (`v-cannot="can.foo.bar"`). When no argument is specified, the directive must immediately follow the preceding `v-can` block so the transformer can re-use that context.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button v-can="can.contract.submit">Submit contract</button>
|
||||||
|
<p v-cannot="can.contract.submit">Contact your admin to unlock submissions.</p>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button v-if="isReady" v-can="can.contract.edit">Edit</button>
|
||||||
|
<p v-cannot>Only editors can update this contract.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Need to wrap the fallback? pass the expression explicitly -->
|
||||||
|
<div class="notice">
|
||||||
|
<p v-cannot="can.contract.edit">Only editors can update this contract.</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `v-cannot` branches above compile to `v-if="!__can__('contract', 'submit')"` and `v-if="!__can__('contract', 'edit')"` respectively.
|
||||||
|
|
||||||
|
### Keep `v-cannot` next to its `v-can`
|
||||||
|
|
||||||
|
When `v-cannot` omits an expression, it must immediately follow the guarded block:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="isReady" v-can="can.foo.bar">Ready!</div>
|
||||||
|
<p v-cannot>Not allowed</p> <!-- ✅ adjacent, guard is inferred -->
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p v-cannot>Not allowed</p> <!-- ❌ wrapped, missing explicit expression -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p v-cannot="can.foo.bar">Not allowed</p> <!-- ✅ wrapper + explicit permission -->
|
||||||
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Rules & Errors
|
## Usage Rules & Errors
|
||||||
|
|
||||||
The transformer validates every template and throws descriptive errors when:
|
The transformer validates every template and throws descriptive errors when:
|
||||||
|
|
||||||
- `v-cannot` does not immediately follow its matching `v-can`.
|
- `v-can` expressions differ within the same `v-if` / `v-else-if` / `v-else` block (the guard is mirrored automatically, but mixed expressions are disallowed).
|
||||||
- `v-can` appears on an element already using `v-else` / `v-else-if`.
|
- `v-cannot` without an argument is separated from its originating `v-can`.
|
||||||
- `v-cannot` uses an argument, modifiers, or a `v-if` condition.
|
- `v-cannot` mixes in modifiers or a `v-if` condition (keep it standalone).
|
||||||
- Multiple `v-cannot` blocks exist for the same `v-can`.
|
- Multiple `v-cannot` blocks exist for the same `v-can`.
|
||||||
- The expression is not a static dotted path like `can.resource.action`.
|
- The expression is not a static dotted path like `can.resource.action`.
|
||||||
|
|
||||||
|
|||||||
12
ROADMAP.md
12
ROADMAP.md
@@ -110,7 +110,7 @@ Avant :
|
|||||||
Après compilation :
|
Après compilation :
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<button v-if="__can__(['employee', 'view'])">Voir le dossier</button>
|
<button v-if="__can__('employee', 'view')">Voir le dossier</button>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Exemple 2 — `v-if` + `v-can`
|
### Exemple 2 — `v-if` + `v-can`
|
||||||
@@ -125,7 +125,7 @@ Avant :
|
|||||||
Après compilation :
|
Après compilation :
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<div v-if="(isReady) && __can__(['contract', 'edit'])">
|
<div v-if="(isReady) && __can__('contract', 'edit')">
|
||||||
Modifier le contrat
|
Modifier le contrat
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
@@ -141,8 +141,8 @@ Avant :
|
|||||||
Après compilation :
|
Après compilation :
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<button v-if="(ctaVisible) && __can__(['employee', 'view'])">Voir</button>
|
<button v-if="(ctaVisible) && __can__('employee', 'view')">Voir</button>
|
||||||
<p v-if="!__can__(['employee', 'view'])">Acces refuse</p>
|
<p v-if="!__can__('employee', 'view')">Acces refuse</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Exemple 4 — `v-if` / `v-else-if` / `v-else` + `v-can` / `v-cannot`
|
### Exemple 4 — `v-if` / `v-else-if` / `v-else` + `v-can` / `v-cannot`
|
||||||
@@ -166,8 +166,8 @@ Résultat :
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<template v-if="isOwner">
|
<template v-if="isOwner">
|
||||||
<button v-if="__can__(['employee', 'edit'])">Modifier</button>
|
<button v-if="__can__('employee', 'edit')">Modifier</button>
|
||||||
<p v-if="!__can__(['employee', 'edit'])">Contactez votre admin</p>
|
<p v-if="!__can__('employee', 'edit')">Contactez votre admin</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isManager">
|
<template v-else-if="isManager">
|
||||||
<p>Vue manager</p>
|
<p>Vue manager</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@eduvia-app/nuxt-can",
|
"name": "@eduvia-app/nuxt-can",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"description": "Nuxt directives (`v-can`, `v-cannot`) to layer permissions without touching business v-ifs.",
|
"description": "Nuxt directives (`v-can`, `v-cannot`) to layer permissions without touching business v-ifs.",
|
||||||
"author": "Eduvia <engineering@eduvia.app>",
|
"author": "Eduvia <engineering@eduvia.app>",
|
||||||
"homepage": "https://github.com/eduvia-app/nuxt-can#readme",
|
"homepage": "https://github.com/eduvia-app/nuxt-can#readme",
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<div v-can="can.baz.dskj">
|
<section class="card">
|
||||||
foo.bar
|
<h1>`v-can` example</h1>
|
||||||
</div>
|
|
||||||
<div v-cannot>
|
<div v-if="phase === 'draft'" v-can="can.foo.bar" class="block">
|
||||||
cannot
|
Draft branch
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="phase === 'pending'" class="block">
|
||||||
|
Pending branch
|
||||||
|
</div>
|
||||||
|
<div v-else class="block">
|
||||||
|
Fallback branch
|
||||||
|
</div>
|
||||||
|
<div v-cannot class="denied">
|
||||||
|
Missing `can.foo.bar` permission
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const phase = 'pending'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -18,66 +29,23 @@
|
|||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
.card {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1.5rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
max-width: 32rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h2 {
|
.block {
|
||||||
margin-top: 0;
|
border: 1px dashed #cbd5f5;
|
||||||
}
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
button {
|
margin-block: 0.5rem;
|
||||||
margin-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghost {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #cbd5f5;
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
border-bottom: 1px solid #edf2f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
li:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.allowed {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.denied {
|
.denied {
|
||||||
|
margin-top: 1rem;
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export function __can__(path: string[]) {
|
export function __can__(...path: string[]) {
|
||||||
const key = path.join('.')
|
const key = path.join('.')
|
||||||
const allowed = new Set([
|
const allowed = new Set([
|
||||||
'employee.view',
|
'employee.view',
|
||||||
@@ -8,5 +8,5 @@ export function __can__(path: string[]) {
|
|||||||
|
|
||||||
const granted = allowed.has(key)
|
const granted = allowed.has(key)
|
||||||
console.log('Checking permission:', key, '->', granted)
|
console.log('Checking permission:', key, '->', granted)
|
||||||
return granted
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const generateTypeDeclaration = (permissions: Record<string, string[]>) =
|
|||||||
|
|
||||||
return `/* eslint-disable */
|
return `/* eslint-disable */
|
||||||
// Generated by nuxt-can – do not edit manually.
|
// Generated by nuxt-can – do not edit manually.
|
||||||
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
|
type NuxtCanChecker = (...path: string[]) => boolean | Promise<boolean>
|
||||||
type NuxtCanPermissions = ${permissionTree}
|
type NuxtCanPermissions = ${permissionTree}
|
||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface NuxtCanRuntimeConfig {
|
|||||||
canFunctionImport: string
|
canFunctionImport: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
|
type NuxtCanChecker = (...path: string[]) => boolean | Promise<boolean>
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|||||||
@@ -15,12 +15,27 @@ interface WalkerContext {
|
|||||||
templateStart: number
|
templateStart: number
|
||||||
patches: Patch[]
|
patches: Patch[]
|
||||||
filename?: string
|
filename?: string
|
||||||
|
directiveCache: WeakMap<ElementNode, DirectiveSet>
|
||||||
|
inheritedCans: WeakMap<ElementNode, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingCan {
|
interface PendingCan {
|
||||||
expression: string
|
expression: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DirectiveSet {
|
||||||
|
canDirective: DirectiveNode | null
|
||||||
|
cannotDirective: DirectiveNode | null
|
||||||
|
ifDirective: DirectiveNode | null
|
||||||
|
elseDirective: DirectiveNode | null
|
||||||
|
elseIfDirective: DirectiveNode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type BranchKind = 'if' | 'else-if' | 'else' | 'plain'
|
||||||
|
|
||||||
|
type DirectiveWithSegments = DirectiveNode & { [CAN_SEGMENTS_SYMBOL]?: string[] }
|
||||||
|
|
||||||
|
const CAN_SEGMENTS_SYMBOL: unique symbol = Symbol('nuxt-can:segments')
|
||||||
const CAN_IDENTIFIER = new Set(['can', '$can'])
|
const CAN_IDENTIFIER = new Set(['can', '$can'])
|
||||||
const SEGMENT_PATTERN = /^[\w-]+$/u
|
const SEGMENT_PATTERN = /^[\w-]+$/u
|
||||||
|
|
||||||
@@ -29,6 +44,8 @@ export function collectCanPatches(ast: RootNode, templateStart: number, filename
|
|||||||
templateStart,
|
templateStart,
|
||||||
patches: [],
|
patches: [],
|
||||||
filename,
|
filename,
|
||||||
|
directiveCache: new WeakMap(),
|
||||||
|
inheritedCans: new WeakMap(),
|
||||||
}
|
}
|
||||||
|
|
||||||
walkChildren(ast.children, ctx)
|
walkChildren(ast.children, ctx)
|
||||||
@@ -37,6 +54,8 @@ export function collectCanPatches(ast: RootNode, templateStart: number, filename
|
|||||||
}
|
}
|
||||||
|
|
||||||
function walkChildren(children: TemplateChildNode[], ctx: WalkerContext): void {
|
function walkChildren(children: TemplateChildNode[], ctx: WalkerContext): void {
|
||||||
|
enforceConditionalChains(children, ctx)
|
||||||
|
|
||||||
let pendingCan: PendingCan | null = null
|
let pendingCan: PendingCan | null = null
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
@@ -71,20 +90,9 @@ function walkChildren(children: TemplateChildNode[], ctx: WalkerContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: PendingCan | null): PendingCan | null {
|
function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: PendingCan | null): PendingCan | null {
|
||||||
let canDirective: DirectiveNode | null = null
|
const directives = getDirectiveSet(element, ctx)
|
||||||
let cannotDirective: DirectiveNode | null = null
|
const { canDirective, cannotDirective, ifDirective, elseDirective, elseIfDirective } = directives
|
||||||
let ifDirective: DirectiveNode | null = null
|
const inheritedSegments = ctx.inheritedCans.get(element) ?? null
|
||||||
let elseDirective: DirectiveNode | null = null
|
|
||||||
let elseIfDirective: DirectiveNode | null = null
|
|
||||||
|
|
||||||
for (const prop of element.props) {
|
|
||||||
if (prop.type !== NodeTypes.DIRECTIVE) continue
|
|
||||||
if (prop.name === 'can') canDirective = prop
|
|
||||||
if (prop.name === 'cannot') cannotDirective = prop
|
|
||||||
if (prop.name === 'if') ifDirective = prop
|
|
||||||
if (prop.name === 'else') elseDirective = prop
|
|
||||||
if (prop.name === 'else-if') elseIfDirective = prop
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canDirective && cannotDirective) {
|
if (canDirective && cannotDirective) {
|
||||||
raiseDirectiveError(ctx, cannotDirective.loc, '`v-can` and `v-cannot` cannot be used on the same element.')
|
raiseDirectiveError(ctx, cannotDirective.loc, '`v-can` and `v-cannot` cannot be used on the same element.')
|
||||||
@@ -104,14 +112,26 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
|
|||||||
if (canDirective) {
|
if (canDirective) {
|
||||||
const expression = transformCanDirective({
|
const expression = transformCanDirective({
|
||||||
canDirective,
|
canDirective,
|
||||||
ifDirective,
|
|
||||||
ctx,
|
ctx,
|
||||||
hasElseLike: Boolean(elseDirective || elseIfDirective),
|
branch: determineBranchKind(directives),
|
||||||
|
conditionDirective: elseIfDirective ?? ifDirective,
|
||||||
|
elseDirective,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return { expression }
|
||||||
expression,
|
}
|
||||||
}
|
|
||||||
|
if (inheritedSegments) {
|
||||||
|
const expression = applyInheritedCanGuard({
|
||||||
|
segments: inheritedSegments,
|
||||||
|
ctx,
|
||||||
|
branch: determineBranchKind(directives),
|
||||||
|
conditionDirective: elseIfDirective ?? ifDirective,
|
||||||
|
elseDirective,
|
||||||
|
loc: element.loc,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { expression }
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -119,17 +139,14 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
|
|||||||
|
|
||||||
function transformCanDirective(params: {
|
function transformCanDirective(params: {
|
||||||
canDirective: DirectiveNode
|
canDirective: DirectiveNode
|
||||||
ifDirective: DirectiveNode | null
|
|
||||||
ctx: WalkerContext
|
ctx: WalkerContext
|
||||||
hasElseLike: boolean
|
branch: BranchKind
|
||||||
|
conditionDirective: DirectiveNode | null
|
||||||
|
elseDirective: DirectiveNode | null
|
||||||
}): string {
|
}): string {
|
||||||
const { canDirective, ifDirective, ctx, hasElseLike } = params
|
const { canDirective, ctx, branch, conditionDirective, elseDirective } = params
|
||||||
|
|
||||||
if (hasElseLike) {
|
if (canDirective.exp && canDirective.exp.type !== NodeTypes.SIMPLE_EXPRESSION) {
|
||||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` cannot be used on `v-else` or `v-else-if` branches; wrap the conditional block in a <template> and apply `v-can` inside it.')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canDirective.exp?.type !== NodeTypes.SIMPLE_EXPRESSION) {
|
|
||||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` expects a static expression (for example `v-can="can.x.y"`).')
|
raiseDirectiveError(ctx, canDirective.loc, '`v-can` expects a static expression (for example `v-can="can.x.y"`).')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,37 +158,34 @@ function transformCanDirective(params: {
|
|||||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` does not accept modifiers.')
|
raiseDirectiveError(ctx, canDirective.loc, '`v-can` does not accept modifiers.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const expressionContent = canDirective.exp.content?.trim()
|
const expressionContent = canDirective.exp?.content?.trim()
|
||||||
if (!expressionContent) {
|
if (!expressionContent) {
|
||||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
|
raiseDirectiveError(ctx, canDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathSegments = parseCanExpression(expressionContent, ctx, canDirective.loc)
|
const pathSegments = getOrParseCanSegments(canDirective, expressionContent, ctx)
|
||||||
const canInvocation = buildCanInvocation(pathSegments)
|
const canInvocation = buildCanInvocation(pathSegments)
|
||||||
|
|
||||||
if (ifDirective && ifDirective.exp?.type === NodeTypes.SIMPLE_EXPRESSION) {
|
if (branch === 'plain') {
|
||||||
const ifExpression = (ifDirective.exp.content || 'true').trim() || 'true'
|
|
||||||
|
|
||||||
ctx.patches.push({
|
|
||||||
start: ctx.templateStart + ifDirective.loc.start.offset,
|
|
||||||
end: ctx.templateStart + ifDirective.loc.end.offset,
|
|
||||||
text: `v-if="(${ifExpression}) && ${canInvocation}"`,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx.patches.push({
|
|
||||||
start: ctx.templateStart + canDirective.loc.start.offset,
|
|
||||||
end: ctx.templateStart + canDirective.loc.end.offset,
|
|
||||||
text: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
ctx.patches.push({
|
ctx.patches.push({
|
||||||
start: ctx.templateStart + canDirective.loc.start.offset,
|
start: ctx.templateStart + canDirective.loc.start.offset,
|
||||||
end: ctx.templateStart + canDirective.loc.end.offset,
|
end: ctx.templateStart + canDirective.loc.end.offset,
|
||||||
text: `v-if="${canInvocation}"`,
|
text: `v-if="${canInvocation}"`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return canInvocation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mergeGuardIntoConditional({
|
||||||
|
branch,
|
||||||
|
conditionDirective,
|
||||||
|
elseDirective,
|
||||||
|
canInvocation,
|
||||||
|
ctx,
|
||||||
|
loc: canDirective.loc,
|
||||||
|
})
|
||||||
|
|
||||||
|
removeDirective(canDirective, ctx)
|
||||||
return canInvocation
|
return canInvocation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +197,8 @@ function transformCannotDirective(params: {
|
|||||||
}): void {
|
}): void {
|
||||||
const { directive, pendingCan, ctx, ifDirective } = params
|
const { directive, pendingCan, ctx, ifDirective } = params
|
||||||
|
|
||||||
if (directive.exp) {
|
if (directive.exp && directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION) {
|
||||||
raiseDirectiveError(ctx, directive.loc, '`v-cannot` must not carry an expression (use `v-cannot` by itself).')
|
raiseDirectiveError(ctx, directive.loc, '`v-cannot` expects a static expression (for example `v-cannot="can.x.y"`).')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directive.arg) {
|
if (directive.arg) {
|
||||||
@@ -199,14 +213,26 @@ function transformCannotDirective(params: {
|
|||||||
raiseDirectiveError(ctx, directive.loc, '`v-cannot` cannot be combined with `v-if`; remove the extra condition.')
|
raiseDirectiveError(ctx, directive.loc, '`v-cannot` cannot be combined with `v-if`; remove the extra condition.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingCan) {
|
let expression = pendingCan?.expression
|
||||||
raiseDirectiveError(ctx, directive.loc, '`v-cannot` must immediately follow its `v-can`, and there can be only one `v-cannot` per `v-can` block.')
|
|
||||||
|
if (directive.exp) {
|
||||||
|
const expressionContent = directive.exp.content?.trim()
|
||||||
|
if (!expressionContent) {
|
||||||
|
raiseDirectiveError(ctx, directive.loc, '`v-cannot` requires a valid permission expression such as `can.employee.view`.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = parseCanExpression(expressionContent, ctx, directive.loc)
|
||||||
|
expression = buildCanInvocation(pathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expression) {
|
||||||
|
raiseDirectiveError(ctx, directive.loc, '`v-cannot` without an expression must immediately follow its matching `v-can`.')
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.patches.push({
|
ctx.patches.push({
|
||||||
start: ctx.templateStart + directive.loc.start.offset,
|
start: ctx.templateStart + directive.loc.start.offset,
|
||||||
end: ctx.templateStart + directive.loc.end.offset,
|
end: ctx.templateStart + directive.loc.end.offset,
|
||||||
text: `v-if="!(${pendingCan.expression})"`,
|
text: `v-if="!(${expression})"`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +263,242 @@ function parseCanExpression(expression: string, ctx: WalkerContext, loc: SourceL
|
|||||||
|
|
||||||
function buildCanInvocation(segments: string[]): string {
|
function buildCanInvocation(segments: string[]): string {
|
||||||
const escapedSegments = segments
|
const escapedSegments = segments
|
||||||
.map(segment => `'${segment.replace(/\\/g, '\\\\').replace(/'/g, '\\u0027')}'`)
|
.map(segment => `'${segment.replace(/\\/g, '\\\\').replace(/'/g, '\u0027')}'`)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
|
|
||||||
return `__can__([${escapedSegments}])`
|
return `__can__(${escapedSegments})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInheritedCanGuard(params: {
|
||||||
|
segments: string[]
|
||||||
|
ctx: WalkerContext
|
||||||
|
branch: BranchKind
|
||||||
|
conditionDirective: DirectiveNode | null
|
||||||
|
elseDirective: DirectiveNode | null
|
||||||
|
loc: SourceLocation
|
||||||
|
}): string {
|
||||||
|
const { segments, ctx, branch, conditionDirective, elseDirective, loc } = params
|
||||||
|
|
||||||
|
if (branch === 'plain') {
|
||||||
|
raiseDirectiveError(ctx, loc, '`v-can` can only be inferred on `v-if` / `v-else-if` / `v-else` chains.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const canInvocation = buildCanInvocation(segments)
|
||||||
|
|
||||||
|
mergeGuardIntoConditional({
|
||||||
|
branch,
|
||||||
|
conditionDirective,
|
||||||
|
elseDirective,
|
||||||
|
canInvocation,
|
||||||
|
ctx,
|
||||||
|
loc,
|
||||||
|
})
|
||||||
|
|
||||||
|
return canInvocation
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeGuardIntoConditional(params: {
|
||||||
|
branch: BranchKind
|
||||||
|
conditionDirective: DirectiveNode | null
|
||||||
|
elseDirective: DirectiveNode | null
|
||||||
|
canInvocation: string
|
||||||
|
ctx: WalkerContext
|
||||||
|
loc: SourceLocation
|
||||||
|
}): void {
|
||||||
|
const { branch, conditionDirective, elseDirective, canInvocation, ctx, loc } = params
|
||||||
|
|
||||||
|
if (branch === 'if') {
|
||||||
|
if (!conditionDirective) {
|
||||||
|
raiseDirectiveError(ctx, loc, '`v-if` metadata missing while transforming `v-can`.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionExpression = getConditionExpression(conditionDirective, ctx, '`v-if` should carry a static expression when paired with `v-can`.')
|
||||||
|
|
||||||
|
ctx.patches.push({
|
||||||
|
start: ctx.templateStart + conditionDirective.loc.start.offset,
|
||||||
|
end: ctx.templateStart + conditionDirective.loc.end.offset,
|
||||||
|
text: `v-if="(${conditionExpression}) && ${canInvocation}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branch === 'else-if') {
|
||||||
|
if (!conditionDirective) {
|
||||||
|
raiseDirectiveError(ctx, loc, '`v-else-if` metadata missing while transforming `v-can`.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionExpression = getConditionExpression(conditionDirective, ctx, '`v-else-if` should carry a static expression when paired with `v-can`.')
|
||||||
|
|
||||||
|
ctx.patches.push({
|
||||||
|
start: ctx.templateStart + conditionDirective.loc.start.offset,
|
||||||
|
end: ctx.templateStart + conditionDirective.loc.end.offset,
|
||||||
|
text: `v-else-if="(${conditionExpression}) && ${canInvocation}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branch === 'else') {
|
||||||
|
if (!elseDirective) {
|
||||||
|
raiseDirectiveError(ctx, loc, '`v-else` metadata missing while transforming `v-can`.')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.patches.push({
|
||||||
|
start: ctx.templateStart + elseDirective.loc.start.offset,
|
||||||
|
end: ctx.templateStart + elseDirective.loc.end.offset,
|
||||||
|
text: `v-else-if="${canInvocation}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineBranchKind(directives: DirectiveSet): BranchKind {
|
||||||
|
if (directives.ifDirective) return 'if'
|
||||||
|
if (directives.elseIfDirective) return 'else-if'
|
||||||
|
if (directives.elseDirective) return 'else'
|
||||||
|
return 'plain'
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceConditionalChains(children: TemplateChildNode[], ctx: WalkerContext): void {
|
||||||
|
for (let index = 0; index < children.length; index += 1) {
|
||||||
|
const child = children[index]
|
||||||
|
if (!isTemplateChild(child) || child.type !== NodeTypes.ELEMENT) continue
|
||||||
|
|
||||||
|
const directives = getDirectiveSet(child as ElementNode, ctx)
|
||||||
|
if (!directives.ifDirective) continue
|
||||||
|
|
||||||
|
const chain = collectConditionalChain(children, index, ctx)
|
||||||
|
if (chain.length === 1) {
|
||||||
|
index = chain[chain.length - 1].index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDirectives = chain.map(member => getDirectiveSet(member.node, ctx).canDirective)
|
||||||
|
const referenceDirective = canDirectives.find((directive): directive is DirectiveNode => Boolean(directive))
|
||||||
|
|
||||||
|
if (!referenceDirective) {
|
||||||
|
index = chain[chain.length - 1].index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressionContent = referenceDirective.exp?.type === NodeTypes.SIMPLE_EXPRESSION ? referenceDirective.exp.content?.trim() : null
|
||||||
|
if (!expressionContent) {
|
||||||
|
raiseDirectiveError(ctx, referenceDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceSegments = getOrParseCanSegments(referenceDirective, expressionContent, ctx)
|
||||||
|
|
||||||
|
for (let i = 0; i < chain.length; i += 1) {
|
||||||
|
const directive = canDirectives[i]
|
||||||
|
if (!directive) {
|
||||||
|
ctx.inheritedCans.set(chain[i].node, referenceSegments)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = directive.exp?.type === NodeTypes.SIMPLE_EXPRESSION ? directive.exp.content?.trim() : null
|
||||||
|
if (!content) {
|
||||||
|
raiseDirectiveError(ctx, directive.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = getOrParseCanSegments(directive, content, ctx)
|
||||||
|
if (!areSegmentsEqual(referenceSegments, segments)) {
|
||||||
|
raiseDirectiveError(ctx, directive.loc, '`v-can` expressions must match across every branch of the conditional chain.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index = chain[chain.length - 1].index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectConditionalChain(children: TemplateChildNode[], startIndex: number, ctx: WalkerContext) {
|
||||||
|
const chain: Array<{ node: ElementNode, index: number }> = [
|
||||||
|
{ node: children[startIndex] as ElementNode, index: startIndex },
|
||||||
|
]
|
||||||
|
|
||||||
|
let cursor = startIndex + 1
|
||||||
|
while (cursor < children.length) {
|
||||||
|
const sibling = children[cursor]
|
||||||
|
|
||||||
|
if (sibling?.type === NodeTypes.COMMENT) {
|
||||||
|
cursor += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sibling?.type === NodeTypes.TEXT && sibling.content.trim().length === 0) {
|
||||||
|
cursor += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTemplateChild(sibling) || sibling.type !== NodeTypes.ELEMENT) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const directives = getDirectiveSet(sibling as ElementNode, ctx)
|
||||||
|
if (directives.elseIfDirective || directives.elseDirective) {
|
||||||
|
chain.push({ node: sibling as ElementNode, index: cursor })
|
||||||
|
cursor += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectiveSet(element: ElementNode, ctx: WalkerContext): DirectiveSet {
|
||||||
|
const cached = ctx.directiveCache.get(element)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const directives: DirectiveSet = {
|
||||||
|
canDirective: null,
|
||||||
|
cannotDirective: null,
|
||||||
|
ifDirective: null,
|
||||||
|
elseDirective: null,
|
||||||
|
elseIfDirective: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prop of element.props) {
|
||||||
|
if (prop.type !== NodeTypes.DIRECTIVE) continue
|
||||||
|
if (prop.name === 'can') directives.canDirective = prop
|
||||||
|
if (prop.name === 'cannot') directives.cannotDirective = prop
|
||||||
|
if (prop.name === 'if') directives.ifDirective = prop
|
||||||
|
if (prop.name === 'else') directives.elseDirective = prop
|
||||||
|
if (prop.name === 'else-if') directives.elseIfDirective = prop
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.directiveCache.set(element, directives)
|
||||||
|
return directives
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConditionExpression(directive: DirectiveNode, ctx: WalkerContext, message: string): string {
|
||||||
|
if (!directive.exp || directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION) {
|
||||||
|
raiseDirectiveError(ctx, directive.loc, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (directive.exp.content || 'true').trim() || 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDirective(directive: DirectiveNode, ctx: WalkerContext): void {
|
||||||
|
ctx.patches.push({
|
||||||
|
start: ctx.templateStart + directive.loc.start.offset,
|
||||||
|
end: ctx.templateStart + directive.loc.end.offset,
|
||||||
|
text: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrParseCanSegments(directive: DirectiveNode, expression: string, ctx: WalkerContext): string[] {
|
||||||
|
const cached = (directive as DirectiveWithSegments)[CAN_SEGMENTS_SYMBOL]
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const segments = parseCanExpression(expression, ctx, directive.loc)
|
||||||
|
;(directive as DirectiveWithSegments)[CAN_SEGMENTS_SYMBOL] = segments
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function areSegmentsEqual(a: string[], b: string[]): boolean {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return a.every((segment, index) => segment === b[index])
|
||||||
}
|
}
|
||||||
|
|||||||
21
test/chains.test.ts
Normal file
21
test/chains.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { setup, $fetch } from '@nuxt/test-utils/e2e'
|
||||||
|
|
||||||
|
describe('chains fixture', async () => {
|
||||||
|
await setup({
|
||||||
|
rootDir: fileURLToPath(new URL('./fixtures/chains', import.meta.url)),
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides every branch when permission is missing and falls back to v-cannot', async () => {
|
||||||
|
const html = await $fetch('/')
|
||||||
|
expect(html).not.toContain('id="branch-pending"')
|
||||||
|
expect(html).toContain('id="branch-denied"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders explicit v-cannot blocks even when detached from the chain', async () => {
|
||||||
|
const html = await $fetch('/')
|
||||||
|
expect(html).toContain('id="explicit-denied"')
|
||||||
|
expect(html).not.toContain('id="approve-action"')
|
||||||
|
})
|
||||||
|
})
|
||||||
4
test/fixtures/basic/permissions/__can__.ts
vendored
4
test/fixtures/basic/permissions/__can__.ts
vendored
@@ -4,7 +4,7 @@ const granted = new Set([
|
|||||||
'contract.create',
|
'contract.create',
|
||||||
])
|
])
|
||||||
|
|
||||||
export function __can__(path: string[]) {
|
export function __can__(...path: string[]) {
|
||||||
const key = Array.isArray(path) ? path.join('.') : String(path)
|
const key = path.join('.')
|
||||||
return granted.has(key)
|
return granted.has(key)
|
||||||
}
|
}
|
||||||
|
|||||||
31
test/fixtures/chains/app.vue
vendored
Normal file
31
test/fixtures/chains/app.vue
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<div id="branch-draft" v-if="phase === 'draft'" v-can="can.employee.view">
|
||||||
|
Draft
|
||||||
|
</div>
|
||||||
|
<div id="branch-pending" v-else-if="phase === 'pending'">
|
||||||
|
Pending
|
||||||
|
</div>
|
||||||
|
<div id="branch-fallback" v-else>
|
||||||
|
Fallback
|
||||||
|
</div>
|
||||||
|
<p id="branch-denied" v-cannot>
|
||||||
|
Permission missing
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<button id="approve-action" v-can="can.contract.approve">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<p id="explicit-denied" v-cannot="can.contract.approve">
|
||||||
|
Need contract.approve
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const phase = 'pending'
|
||||||
|
</script>
|
||||||
12
test/fixtures/chains/nuxt.config.ts
vendored
Normal file
12
test/fixtures/chains/nuxt.config.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import NuxtCan from '../../../src/module'
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: [NuxtCan],
|
||||||
|
nuxtCan: {
|
||||||
|
permissions: {
|
||||||
|
employee: ['view'],
|
||||||
|
contract: ['approve'],
|
||||||
|
},
|
||||||
|
canFunctionImport: '~/permissions/__can__',
|
||||||
|
},
|
||||||
|
})
|
||||||
5
test/fixtures/chains/package.json
vendored
Normal file
5
test/fixtures/chains/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"name": "chains",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
6
test/fixtures/chains/permissions/__can__.ts
vendored
Normal file
6
test/fixtures/chains/permissions/__can__.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const granted = new Set<string>([])
|
||||||
|
|
||||||
|
export function __can__(...path: string[]) {
|
||||||
|
const key = path.join('.')
|
||||||
|
return granted.has(key)
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ describe('transformCan', () => {
|
|||||||
<p v-cannot>Refus</p>
|
<p v-cannot>Refus</p>
|
||||||
`)
|
`)
|
||||||
|
|
||||||
expect(code).toContain(`v-if="__can__(['employee', 'view'])"`)
|
expect(code).toContain(`v-if="__can__('employee', 'view')"`)
|
||||||
expect(code).toContain(`v-if="!(__can__(['employee', 'view']))"`)
|
expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('merges existing v-if expressions with the generated guard', () => {
|
it('merges existing v-if expressions with the generated guard', () => {
|
||||||
@@ -27,7 +27,7 @@ describe('transformCan', () => {
|
|||||||
<div v-if="isReady" v-can="can.contract.create" />
|
<div v-if="isReady" v-can="can.contract.create" />
|
||||||
`)
|
`)
|
||||||
|
|
||||||
expect(code).toContain(`v-if="(isReady) && __can__(['contract', 'create'])"`)
|
expect(code).toContain(`v-if="(isReady) && __can__('contract', 'create')"`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws when v-cannot is used without a preceding v-can', () => {
|
it('throws when v-cannot is used without a preceding v-can', () => {
|
||||||
@@ -36,7 +36,7 @@ describe('transformCan', () => {
|
|||||||
id: TEST_FILE,
|
id: TEST_FILE,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(exec).toThrow(/must immediately follow its `v-can`/)
|
expect(exec).toThrow(/without an expression must immediately follow/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws when the expression does not start with can.*', () => {
|
it('throws when the expression does not start with can.*', () => {
|
||||||
@@ -48,15 +48,61 @@ describe('transformCan', () => {
|
|||||||
expect(exec).toThrow(/expressions must start with `can\.`/)
|
expect(exec).toThrow(/expressions must start with `can\.`/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws when v-can is added to a v-else branch', () => {
|
it('mirrors guards across v-if / v-else-if / v-else chains when the first branch uses v-can', () => {
|
||||||
|
const code = runTransform(`
|
||||||
|
<div v-if="ready" v-can="can.employee.view"></div>
|
||||||
|
<div v-else-if="later"></div>
|
||||||
|
<div v-else></div>
|
||||||
|
<p v-cannot>Denied</p>
|
||||||
|
`)
|
||||||
|
|
||||||
|
expect(code).toContain(`v-if="(ready) && __can__('employee', 'view')"`)
|
||||||
|
expect(code).toContain(`v-else-if="(later) && __can__('employee', 'view')"`)
|
||||||
|
expect(code).toContain(`v-else-if="__can__('employee', 'view')"`)
|
||||||
|
expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when branches declare different v-can expressions', () => {
|
||||||
const exec = () => transformCan({
|
const exec = () => transformCan({
|
||||||
code: buildSFC(`
|
code: buildSFC(`
|
||||||
<div v-if="ready"></div>
|
<div v-if="ready" v-can="can.employee.view"></div>
|
||||||
<div v-else v-can="can.employee.view"></div>
|
<div v-else v-can="can.contract.create"></div>
|
||||||
`),
|
`),
|
||||||
id: TEST_FILE,
|
id: TEST_FILE,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(exec).toThrow(/cannot be used on `v-else`/)
|
expect(exec).toThrow(/must match across every branch/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows explicit expressions on v-cannot without a preceding v-can', () => {
|
||||||
|
const code = runTransform('<p v-cannot="can.employee.view">Denied</p>')
|
||||||
|
expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires adjacent placement for v-cannot without an expression', () => {
|
||||||
|
const exec = () => transformCan({
|
||||||
|
code: buildSFC(`
|
||||||
|
<div v-can="can.employee.view">Allowed</div>
|
||||||
|
<div>
|
||||||
|
<p v-cannot>Denied</p>
|
||||||
|
</div>
|
||||||
|
`),
|
||||||
|
id: TEST_FILE,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(exec).toThrow(/without an expression must immediately follow/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires adjacency even when only empty siblings exist between v-can and v-cannot', () => {
|
||||||
|
const exec = () => transformCan({
|
||||||
|
code: buildSFC(`
|
||||||
|
<div v-can="can.employee.view">Allowed</div>
|
||||||
|
<div></div>
|
||||||
|
<div v-cannot>Denied</div>
|
||||||
|
`),
|
||||||
|
id: TEST_FILE,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(exec).toThrow(/without an expression must immediately follow/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user