Merge pull request #2 from sofico-global/feat/queryparamnavigation

feat(): Add QueryParam navigation
This commit is contained in:
Depickere Sven 2023-02-22 14:47:46 +01:00 committed by GitHub
commit 01dad756d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 113 additions and 27 deletions

View File

@ -251,6 +251,7 @@ You can use all of the following options with the standalone version of the <red
* **path-only**: displays a path in the sidebar navigation item.
* **id-only**: displays the operation id with a fallback to the path in the sidebar navigation item.
* `showWebhookVerb` - when set to `true`, shows the HTTP request method for webhooks in operations and in the sidebar.
* `userQueryParamToNavigate` - when set to `true`, the navigation in the URL will now use query param instead with the key `redoc`
### `<redoc>` theme object
* `spacing`

View File

@ -1,7 +1,7 @@
describe('Search', () => {
const getSearchInput = () => cy.get('[role="search"] input');
const getSearchResults = () => cy.get('[data-role="search:results"]');
const getResult = i => cy.get('[role=search] [role=menuitem]').eq(i);
const getResult = i => cy.get('[role=search] [role=menuitem]').eq(i).find('label');
beforeEach(() => {
cy.visit('e2e/standalone.html');
@ -45,7 +45,7 @@ describe('Search', () => {
getSearchInput().type('{enter}', { force: true });
cy.contains('[role=menu] [role=menuitem]', 'Introduction').should('have.class', 'active');
cy.contains('[role=menu] [role=menuitem] label', 'Introduction').should('have.class', 'active');
});
it('should mark search results', () => {

View File

@ -292,6 +292,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Dog/properties/packSize",
@ -563,6 +564,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Dog/properties/type",
@ -821,6 +823,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Dog",
@ -1141,6 +1144,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Cat/properties/type",
@ -1424,6 +1428,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Cat/properties/packSize",
@ -1678,6 +1683,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Cat",
@ -1957,6 +1963,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Pet",
@ -2266,6 +2273,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Dog/properties/packSize",
@ -2537,6 +2545,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Dog/properties/type",
@ -2795,6 +2804,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
},
"unstable_ignoreMimeParameters": false,
"untrustedSpec": false,
"userQueryParamToNavigate": false,
},
"pattern": undefined,
"pointer": "#/components/schemas/Dog",

View File

@ -2,7 +2,7 @@ import { Lambda, observe } from 'mobx';
import type { OpenAPISpec } from '../types';
import { loadAndBundleSpec } from '../utils/loadAndBundleSpec';
import { history } from './HistoryService';
import { HistoryService } from './HistoryService';
import { MarkerService } from './MarkerService';
import { MenuStore } from './MenuStore';
import { SpecStore } from './models';
@ -53,6 +53,7 @@ export class AppStore {
options: RedocNormalizedOptions;
search?: SearchStore<string>;
marker = new MarkerService();
history;
private scroll: ScrollService;
private disposer: Lambda | null = null;
@ -65,16 +66,17 @@ export class AppStore {
) {
this.rawOptions = options;
this.options = new RedocNormalizedOptions(options, DEFAULT_OPTIONS);
this.history = new HistoryService(this.options);
this.scroll = new ScrollService(this.options);
// update position statically based on hash (in case of SSR)
MenuStore.updateOnHistory(history.currentId, this.scroll);
MenuStore.updateOnHistory(this.history.currentId, this.scroll);
// override the openApi standard to version 3.1.0
// TODO remove when fully supporting open API 3.1.0
spec.openapi = "3.1.0";
this.spec = new SpecStore(spec, specUrl, this.options);
this.menu = new MenuStore(this.spec, this.scroll, history);
this.menu = new MenuStore(this.spec, this.scroll, this.history);
if (!this.options.disableSearch) {
this.search = new SearchStore();

View File

@ -1,26 +1,36 @@
import { bind, debounce } from 'decko';
import { EventEmitter } from 'eventemitter3';
import { IS_BROWSER } from '../utils/';
import { RedocNormalizedOptions } from './RedocNormalizedOptions';
const EVENT = 'hashchange';
export class HistoryService {
private _emiter;
private options: RedocNormalizedOptions;
constructor() {
constructor(options: RedocNormalizedOptions) {
this.options = options;
this._emiter = new EventEmitter();
this.bind();
}
get currentId(): string {
return IS_BROWSER ? decodeURIComponent(window.location.hash.substring(1)) : '';
if (IS_BROWSER) {
if (this.shouldQueryParamNavigationBeUsed()) {
return this.getQueryParams(window.location.search);
} else {
return decodeURIComponent(window.location.hash.substring(1));
}
}
return '';
}
linkForId(id: string) {
if (!id) {
return '';
}
return '#' + id;
return this.getHrefSplitCharacter() + id;
}
subscribe(cb): () => void {
@ -58,20 +68,32 @@ export class HistoryService {
window.history.replaceState(
null,
'',
window.location.href.split('#')[0] + this.linkForId(id),
window.location.href.split(this.getHrefSplitCharacter())[0] + this.linkForId(id),
);
return;
}
window.history.pushState(null, '', window.location.href.split('#')[0] + this.linkForId(id));
window.history.pushState(
null,
'',
window.location.href.split(this.getHrefSplitCharacter())[0] + this.linkForId(id),
);
this.emit();
}
}
export const history = new HistoryService();
private shouldQueryParamNavigationBeUsed(): boolean {
return this.options?.userQueryParamToNavigate;
}
if (module.hot) {
module.hot.dispose(() => {
history.dispose();
});
private getQueryParams(search: string): string {
const queryParams = new URLSearchParams(search);
if (search != null) {
return queryParams.get('redoc') != null ? (queryParams.get('redoc') as string) : '';
}
return '';
}
private getHrefSplitCharacter(): string {
return this.shouldQueryParamNavigationBeUsed() ? '?redoc=' : '#';
}
}

View File

@ -2,7 +2,7 @@ import { action, observable, makeObservable } from 'mobx';
import { querySelector } from '../utils/dom';
import { escapeHTMLAttrChars, flattenByProp, SECURITY_SCHEMES_SECTION_PREFIX } from '../utils';
import { history as historyInst, HistoryService } from './HistoryService';
import { HistoryService } from './HistoryService';
import { GROUP_DEPTH } from './MenuBuilder';
import type { SpecStore } from './models';
@ -21,7 +21,7 @@ export class MenuStore {
* Statically try update scroll position
* Used before hydrating from server-side rendered html to scroll page faster
*/
static updateOnHistory(id: string = historyInst.currentId, scroll: ScrollService) {
static updateOnHistory(id: string, scroll: ScrollService) {
if (!id) {
return;
}

View File

@ -56,6 +56,7 @@ export interface RedocRawOptions {
hideFab?: boolean;
minCharacterLengthToInitSearch?: number;
showWebhookVerb?: boolean;
userQueryParamToNavigate?: boolean;
}
export function argValueToBoolean(val?: string | boolean, defaultValue?: boolean): boolean {
@ -256,6 +257,7 @@ export class RedocNormalizedOptions {
hideFab: boolean;
minCharacterLengthToInitSearch: number;
showWebhookVerb: boolean;
userQueryParamToNavigate: boolean;
nonce?: string;
@ -335,5 +337,6 @@ export class RedocNormalizedOptions {
this.hideFab = argValueToBoolean(raw.hideFab);
this.minCharacterLengthToInitSearch = argValueToNumber(raw.minCharacterLengthToInitSearch) || 3;
this.showWebhookVerb = argValueToBoolean(raw.showWebhookVerb);
this.userQueryParamToNavigate = argValueToBoolean(raw.userQueryParamToNavigate);
}
}

View File

@ -1,13 +1,32 @@
import { history } from '../HistoryService';
import { HistoryService } from '../HistoryService';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
const options = new RedocNormalizedOptions({});
describe('History service', () => {
function mockWindowLocationForSearch(): void {
const mockResponse = jest.fn();
Object.defineProperty(window, 'location', {
value: {
hash: {
endsWith: mockResponse,
includes: mockResponse,
},
assign: mockResponse,
},
writable: true,
});
}
test('should be an instance', () => {
const history = new HistoryService(options);
expect(typeof history).not.toBe('function');
expect(history.subscribe).toBeDefined();
});
test('History subscribe', () => {
const fn = jest.fn();
const history = new HistoryService(options);
history.subscribe(fn);
history.emit();
expect(fn).toHaveBeenCalled();
@ -15,6 +34,7 @@ describe('History service', () => {
test('History subscribe should return unsubscribe function', () => {
const fn = jest.fn();
const history = new HistoryService(options);
const unsubscribe = history.subscribe(fn);
history.emit();
expect(fn).toHaveBeenCalled();
@ -23,16 +43,44 @@ describe('History service', () => {
expect(fn).toHaveBeenCalledTimes(1);
});
test('currentId should return correct id', () => {
window.location.hash = '#testid';
expect(history.currentId).toEqual('testid');
describe('History with config property: `userQueryParamToNavigate` false', () => {
test('currentId should return correct id', () => {
window.location.hash = '#testid';
const history = new HistoryService(options);
expect(history.currentId).toEqual('testid');
});
test('should return correct link for id', () => {
const history = new HistoryService(options);
expect(history.linkForId('testid')).toEqual('#testid');
});
test('should return empty link for empty id', () => {
const history = new HistoryService(options);
expect(history.linkForId('')).toEqual('');
});
});
test('should return correct link for id', () => {
expect(history.linkForId('testid')).toEqual('#testid');
});
describe('History with config property: `userQueryParamToNavigate` true', () => {
const overrideOptions = new RedocNormalizedOptions({
userQueryParamToNavigate: true,
});
test('should return empty link for empty id', () => {
expect(history.linkForId('')).toEqual('');
test('currentId should return correct id', () => {
mockWindowLocationForSearch();
window.location.search = '?redoc=testid';
const history = new HistoryService(overrideOptions);
expect(history.currentId).toEqual('testid');
});
test('should return correct link for id', () => {
const history = new HistoryService(overrideOptions);
expect(history.linkForId('testid')).toEqual('?redoc=testid');
});
test('should return empty link for empty id', () => {
const history = new HistoryService(overrideOptions);
expect(history.linkForId('')).toEqual('');
});
});
});