Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rule.if #77

Merged
merged 5 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ Using this option, you can re-enable it for selected visits:

Optional, Type: `boolean | string` – If you have [Accessibility Plugin](https://github.com/swup/a11y-plugin/) installed, you can adjust which element to focus for the visit [as described here](https://github.com/swup/a11y-plugin/#visita11yfocus).

#### rule.if

Optional, Type: `(visit) => boolean` – Provide a predicate function for fine-grained control over the matching behavior of a rule.

A predicate function that allows for fine-grained control over the matching behavior of a rule. This function receives the current [visit](https://swup.js.org/visit/) as a parameter, and must return a boolean value. If the function returns `false`, the rule is being skipped for the current visit, even if it matches the current route.

### debug

Type: `boolean`. Set to `true` for debug information in the console. Defaults to `false`.
Expand Down
42 changes: 30 additions & 12 deletions src/SwupFragmentPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import type { Options, Rule, Route, FragmentVisit } from './inc/defs.js';
import * as handlers from './inc/handlers.js';
import { __DEV__ } from './inc/env.js';
import type { Visit } from 'swup';

type RequireKeys<T, K extends keyof T> = Partial<T> & Pick<T, K>;
type InitOptions = RequireKeys<Options, 'rules'>;
Expand Down Expand Up @@ -84,47 +85,64 @@ export default class SwupFragmentPlugin extends PluginBase {
cleanupFragmentElements();
}

/**
* Set completely new rules
*
* @access public
*/
setRules(rules: Rule[]) {
this._rawRules = cloneRules(rules);
this._parsedRules = rules.map((rule) => this.parseRule(rule));
if (__DEV__) this.logger?.log('Updated fragment rules', this.getRules());
}

/**
* Get a clone of the current rules
*
* @access public
*/
getRules() {
return cloneRules(this._rawRules);
}

/**
* Prepend a rule to the existing rules
*
* @access public
*/
prependRule(rule: Rule) {
this.setRules([rule, ...this.getRules()]);
}

/**
* Append a rule to the existing rules
*
* @access public
*/
appendRule(rule: Rule) {
this.setRules([...this.getRules(), rule]);
}

/**
* Add a fragment rule
* @param {Rule} rule The rule options
* @param {'start' | 'end'} at Should the rule be added to the beginning or end of the existing rules?
* Parse a rule (for e.g. debugging)
*
* @access public
*/
parseRule({ from, to, containers, name, scroll, focus }: Rule): ParsedRule {
parseRule(rule: Rule): ParsedRule {
return new ParsedRule({
from,
to,
containers,
name,
scroll,
focus,
...rule,
logger: this.logger,
swup: this.swup
});
}

/**
* Get the fragment visit object for a given route
*
* @access public
*/
getFragmentVisit(route: Route): FragmentVisit | undefined {
const rule = getFirstMatchingRule(route, this.parsedRules);
getFragmentVisit(route: Route, visit?: Visit): FragmentVisit | undefined {
const rule = getFirstMatchingRule(route, this.parsedRules, visit || this.swup.visit);

// Bail early if no rule matched
if (!rule) return;
Expand Down
23 changes: 13 additions & 10 deletions src/inc/ParsedRule.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { matchPath, classify, Location } from 'swup';
import type { Swup, Path } from 'swup';
import type { Route } from './defs.js';
import type { Swup, Path, Visit } from 'swup';
import type { Route, Rule, Predicate } from './defs.js';
import { dedupe, queryFragmentElement } from './functions.js';
import Logger, { highlight } from './Logger.js';
import { __DEV__ } from './env.js';

type Options = {
type Options = Rule & {
swup: Swup;
from: Path;
to: Path;
containers: string[];
name?: string;
scroll?: boolean | string;
focus?: boolean | string;
logger?: Logger;
};

Expand All @@ -31,6 +25,7 @@ export default class ParsedRule {
scroll: boolean | string = false;
focus?: boolean | string;
logger?: Logger;
if: Predicate = () => true;

constructor(options: Options) {
this.swup = options.swup;
Expand All @@ -41,6 +36,7 @@ export default class ParsedRule {
if (options.name) this.name = classify(options.name);
if (typeof options.scroll !== 'undefined') this.scroll = options.scroll;
if (typeof options.focus !== 'undefined') this.focus = options.focus;
if (typeof options.if === 'function') this.if = options.if;

this.containers = this.parseContainers(options.containers);

Expand Down Expand Up @@ -103,7 +99,14 @@ export default class ParsedRule {
/**
* Checks if a given route matches this rule
*/
public matches(route: Route): boolean {
public matches(route: Route, visit: Visit): boolean {
if (!this.if(visit)) {
if (__DEV__) {
this.logger?.log(`ignoring fragment rule due to custom rule.if:`, this);
}
return false;
}

const { url: fromUrl } = Location.fromUrl(route.from);
const { url: toUrl } = Location.fromUrl(route.to);

Expand Down
5 changes: 4 additions & 1 deletion src/inc/defs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Path } from 'swup';
import type { Path, Visit } from 'swup';

/** Represents a route from one to another URL */
export type Route = {
Expand All @@ -15,6 +15,8 @@ export interface FragmentElement extends HTMLElement {
};
}

export type Predicate = (visit: Visit) => boolean;

/** A fragment rule */
export type Rule = {
from: Path;
Expand All @@ -23,6 +25,7 @@ export type Rule = {
name?: string;
scroll?: boolean | string;
focus?: boolean | string;
if?: Predicate;
};

/** The plugin options */
Expand Down
21 changes: 17 additions & 4 deletions src/inc/functions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Location } from 'swup';
import type { Swup, Visit, VisitScroll } from 'swup';
import Swup, { Location } from 'swup';
import type { Visit, VisitScroll } from 'swup';
import type { default as FragmentPlugin } from '../SwupFragmentPlugin.js';
import type { Route, Rule, FragmentVisit, FragmentElement } from './defs.js';
import type ParsedRule from './ParsedRule.js';
Expand Down Expand Up @@ -230,8 +230,12 @@ export const toggleFragmentVisitClass = (
/**
* Get the first matching rule for a given route
*/
export const getFirstMatchingRule = (route: Route, rules: ParsedRule[]): ParsedRule | undefined => {
return rules.find((rule) => rule.matches(route));
export const getFirstMatchingRule = (
route: Route,
rules: ParsedRule[],
visit: Visit
): ParsedRule | undefined => {
return rules.find((rule) => rule.matches(route, visit));
};

/**
Expand Down Expand Up @@ -392,3 +396,12 @@ export function cloneRules(rules: Rule[]): Rule[] {
containers: [...rule.containers]
}));
}

/**
* Create a visit object for tests
*/
export function stubVisit(options: { from?: string; to: string }) {
const swup = new Swup();
// @ts-expect-error swup.createVisit is protected
return swup.createVisit(options);
}
4 changes: 2 additions & 2 deletions src/inc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const onLinkToSelf: Handler<'link:self'> = function (this: FragmentPlugin
const route = getRoute(visit);
if (!route) return;

const rule = getFirstMatchingRule(route, this.parsedRules);
const rule = getFirstMatchingRule(route, this.parsedRules, visit);

if (rule) visit.scroll.reset = false;
};
Expand All @@ -34,7 +34,7 @@ export const onVisitStart: Handler<'visit:start'> = async function (this: Fragme
const route = getRoute(visit);
if (!route) return;

const fragmentVisit = this.getFragmentVisit(route);
const fragmentVisit = this.getFragmentVisit(route, visit);

/**
* Bail early if the current route doesn't match
Expand Down
22 changes: 15 additions & 7 deletions tests/vitest/ParsedRule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ParsedRule from '../../src/inc/ParsedRule.js';
import Logger from '../../src/inc/Logger.js';
import { spyOnConsole, stubGlobalDocument } from './inc/helpers.js';
import Swup from 'swup';
import { stubVisit } from '../../src/inc/functions.js';

describe('ParsedRule', () => {
afterEach(() => {
Expand Down Expand Up @@ -93,12 +94,18 @@ describe('ParsedRule', () => {
from: '/users/',
to: '/user/:slug',
containers: ['#fragment-1'],
swup: new Swup()
swup: new Swup(),
if: (visit) => true
});
expect(rule.matches({ from: '/users/', to: '/user/jane' })).toBe(true);
expect(rule.matches({ from: '/users/', to: '/users/' })).toBe(false);
expect(rule.matches({ from: '/user/jane', to: '/users/' })).toBe(false);
expect(rule.matches({ from: '/user/jane', to: '/user/john' })).toBe(false);
const visit = stubVisit({ to: '' });
expect(rule.matches({ from: '/users/', to: '/user/jane' }, visit)).toBe(true);
expect(rule.matches({ from: '/users/', to: '/users/' }, visit)).toBe(false);
expect(rule.matches({ from: '/user/jane', to: '/users/' }, visit)).toBe(false);
expect(rule.matches({ from: '/user/jane', to: '/user/john' }, visit)).toBe(false);

/** Respect rule.if */
rule.if = (visit) => false;
expect(rule.matches({ from: '/users/', to: '/user/jane' }, visit)).toBe(false);
});

it('should validate selectors if matching a rule', () => {
Expand All @@ -110,17 +117,18 @@ describe('ParsedRule', () => {
swup: new Swup(),
logger: new Logger()
});
const visit = stubVisit({ to: '' });

/** fragment element missing */
stubGlobalDocument(/*html*/ `<div id="swup" class="transition-main"></div>`);
expect(rule.matches({ from: '/foo/', to: '/bar/' })).toBe(false);
expect(rule.matches({ from: '/foo/', to: '/bar/' }, visit)).toBe(false);
expect(console.error).toBeCalledWith(new Error('skipping rule since #fragment-1 doesn\'t exist in the current document'), expect.any(Object)) // prettier-ignore

/** fragment element outside of swup's default containers */
stubGlobalDocument(
/*html*/ `<div id="swup" class="transition-main"></div><div id="fragment-1"></div>`
);
expect(rule.matches({ from: '/foo/', to: '/bar/' })).toBe(false);
expect(rule.matches({ from: '/foo/', to: '/bar/' }, visit)).toBe(false);
expect(console.error).toBeCalledWith(new Error('skipping rule since #fragment-1 is outside of swup\'s default containers'), expect.any(Object)) // prettier-ignore
});
});
5 changes: 2 additions & 3 deletions tests/vitest/adjustVisitScroll.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { describe, expect, it } from 'vitest';
import { adjustVisitScroll } from '../../src/inc/functions.js';
import { adjustVisitScroll, stubVisit } from '../../src/inc/functions.js';
import Swup from 'swup';

describe('adjustVisitScroll()', () => {
it('adjust visit.scroll', () => {
// @ts-expect-error
const { scroll } = new Swup().createVisit({ to: '' });
const { scroll } = stubVisit({ to: '' });

expect(adjustVisitScroll({ containers: [], scroll: true }, scroll)).toEqual({
reset: true
Expand Down
17 changes: 6 additions & 11 deletions tests/vitest/getRoute.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import { describe, expect, it } from 'vitest';
import { getRoute } from '../../src/inc/functions.js';
import { getRoute, stubVisit } from '../../src/inc/functions.js';
import Swup from 'swup';

describe('getRoute()', () => {
it('should get the route from a visit', () => {
const swup = new Swup();
const route = { from: '/page-1/', to: '/page-2/' };
// @ts-expect-error createVisit is protected
const visit = swup.createVisit(route);
const visit = stubVisit(route);
expect(getRoute(visit)).toEqual(route);
});

it('should return undefined for incomplete visits', () => {
const swup = new Swup();
// @ts-expect-error createVisit is protected
const withoutTo = swup.createVisit({ to: '' });
expect(getRoute(withoutTo)).toEqual(undefined);
const withEmptyTo = stubVisit({ to: '' });
expect(getRoute(withEmptyTo)).toEqual(undefined);

// @ts-expect-error createVisit is protected
const withoutFrom = swup.createVisit({ from: '', to: '/page-2/' });
expect(getRoute(withoutFrom)).toEqual(undefined);
const withEmptyFrom = stubVisit({ from: '', to: '/page-2/' });
expect(getRoute(withEmptyFrom)).toEqual(undefined);
});
});
Loading