(core) Initial data tables page

Summary:
- Added a new special page for viewing raw data widgets:
  - Implemented in DataTables.ts
  - Accessible only via the special URL path `/p/data`
  - Future diffs should make this page prettier and easily accessible
  - Shows a list of user tables
  - Clicking on a table name shows its `rawViewSection` by setting `GristDoc.viewModel.activeSectionId`. Note that in this case `GristDoc.viewModel` is an empty record, so this is a bit of a hack, but it works well and causes no known issues.
- Added `ViewSectionRec.isRaw` to know if the record represents a raw data widget.
- Added various restrictions in the UI for raw data widgets:
  - 'Delete widget' is disabled in the 3-dot widget menu.
  - Prevent hiding columns:
    - "Hide column" in the column context menu is disabled
    - The "VISIBLE/HIDDEN COLUMNS" section of the right panel > Table > Widget is hidden
  - The toggle bar isn't configurable to ensure that users know when raw data is filtered:
    - The filter bar always shows if and only if some filters are present
    - "Toggle Filter Bar" is hidden in:
      - Right panel > Table > Sort & Filter
      - The sort/filter menu next to the three-dot menu for widgets.
  - Other restrictions in the right panel:
    - In the Column tab:
      - 'Use separate settings' is disabled
    - In the Table tab:
      - In the Widget subtab:
        - 'Change Widget' is hidden
      - In the Data subtab:
        - 'Edit Data Selection' is hidden
        - 'SELECT BY' is hidden

Test Plan: Tested manually. The behaviour of raw data widgets may still change and they aren't easily visible to users yet.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3248
This commit is contained in:
Alex Hall 2022-02-07 16:02:26 +02:00
parent 0f4153dc23
commit 592a43ec36
15 changed files with 193 additions and 111 deletions

View File

@ -0,0 +1,32 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {buildViewSectionDom, ViewSectionHelper} from 'app/client/components/ViewLayout';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {Disposable, dom, domComputed} from 'grainjs';
export class DataTables extends Disposable {
constructor(private _gristDoc: GristDoc) {
super();
}
public buildDom() {
return [
dom(
'ul',
this._gristDoc.docModel.allTables.all().map(t => dom(
'li', t.rawViewSection().title() || t.tableId(),
dom.on('click', () => this._gristDoc.viewModel.activeSectionId(t.rawViewSection.peek().getRowId())),
))
),
domComputed<ViewSectionRec>(
this._gristDoc.viewModel.activeSection,
(viewSection) => {
if (!viewSection.getRowId()) {
return;
}
ViewSectionHelper.create(this, this._gristDoc, viewSection);
return buildViewSectionDom(this._gristDoc, viewSection.getRowId());
}
)
];
}
}

View File

@ -1495,6 +1495,7 @@ GridView.prototype._getColumnMenuOptions = function(copySelection) {
numFrozen: this.viewSection.numFrozen.peek(),
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
isRaw: this.viewSection.isRaw(),
isFiltered: this.isFiltered(),
isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()),
};

View File

@ -11,6 +11,7 @@ import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel';
import * as commands from 'app/client/components/commands';
import {CursorPos} from 'app/client/components/Cursor';
import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor";
import {DataTables} from 'app/client/components/DataTables';
import {DocComm, DocUserAction} from 'app/client/components/DocComm';
import * as DocConfigTab from 'app/client/components/DocConfigTab';
import {Drafts} from "app/client/components/Drafts";
@ -350,6 +351,7 @@ export class GristDoc extends DisposableWithEvents {
dom.domComputed<IDocPage>(this.activeViewId, (viewId) => (
viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) :
viewId === 'acl' ? dom.create((owner) => owner.autoDispose(AccessRules.create(this, this))) :
viewId === 'data' ? dom.create((owner) => owner.autoDispose(DataTables.create(this, this))) :
viewId === 'GristDocTour' ? null :
dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId)))
)),
@ -732,7 +734,7 @@ export class GristDoc extends DisposableWithEvents {
const srcTable = await this._getTableData(srcSection);
const query: ClientQuery = {tableId: srcTable.tableId, filters: {}, operations: {}};
if (colId) {
query.operations![colId] = isRefListType(section.linkSrcCol.peek().type.peek()) ? 'intersects' : 'in';
query.operations[colId] = isRefListType(section.linkSrcCol.peek().type.peek()) ? 'intersects' : 'in';
query.filters[colId] = isList(controller) ? controller.slice(1) : [controller];
} else {
// must be a summary -- otherwise dealt with earlier.
@ -740,7 +742,7 @@ export class GristDoc extends DisposableWithEvents {
for (const srcCol of srcSection.table.peek().groupByColumns.peek()) {
const filterColId = srcCol.summarySource.peek().colId.peek();
controller = destTable.getValue(cursorPos.rowId, filterColId);
query.operations![filterColId] = 'in';
query.operations[filterColId] = 'in';
query.filters[filterColId] = isList(controller) ? controller.slice(1) : [controller];
}
}

View File

@ -444,8 +444,8 @@ ViewConfigTab.prototype._buildFilterDom = function() {
);
}),
),
cssRow(
cssTextBtn(
grainjsDom.maybe((use) => !use(section.isRaw),
() => cssRow(cssTextBtn(
testId('toggle-filter-bar'),
grainjsDom.domComputed((use) => {
const filterBar = use(activeFilterBar);
@ -457,8 +457,7 @@ ViewConfigTab.prototype._buildFilterDom = function() {
}),
grainjsDom.on('click', () => activeFilterBar(!activeFilterBar.peek())),
'Toggle Filter Bar',
)
),
))),
grainjsDom.maybe(hasChangedObs, () => cssRow(
cssExtraMarginTop.cls(''),
testId('save-filter-btns'),

View File

@ -43,7 +43,7 @@ function getInstanceConstructor(parentKey: string) {
return Cons || viewSectionTypes.record;
}
class ViewSectionHelper extends Disposable {
export class ViewSectionHelper extends Disposable {
private _instance = Holder.create<BaseView>(this);
constructor(gristDoc: GristDoc, vs: ViewSectionRec) {
@ -183,49 +183,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
}
private _buildLeafContent(sectionRowId: number) {
// Creating normal section dom
const vs: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionRowId);
return dom('div.view_leaf.viewsection_content.flexvbox.flexauto',
testId(`viewlayout-section-${sectionRowId}`),
cssViewLeaf.cls(''),
cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)),
dom.cls('active_section', vs.hasFocus),
dom.maybe<BaseView|null>((use) => use(vs.viewInstance), (viewInstance) => dom('div.viewsection_title.flexhbox',
dom('span.viewsection_drag_indicator.glyphicon.glyphicon-option-vertical',
// Makes element grabbable only if grist is not readonly.
dom.cls('layout_grabbable', (use) => !use(this.gristDoc.isReadonlyKo))),
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
cssSigmaIcon('Pivot', testId('sigma'))),
dom('div.viewsection_titletext_container.flexitem.flexhbox',
dom('span.viewsection_titletext', editableLabel(
fromKo(vs.titleDef),
(val) => vs.titleDef.saveOnly(val),
testId('viewsection-title'),
)),
),
viewInstance.buildTitleControls(),
dom('span.viewsection_buttons',
dom.create(viewSectionMenu, this.docModel, vs, this.viewModel, this.gristDoc.isReadonly)
)
)),
dom.maybe(vs.activeFilterBar, () => dom.create(filterBar, vs)),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) =>
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', this._isResizing),
dom.maybe(viewInstance.disableEditing, () =>
dom('div.disable_viewpane.flexvbox', 'No data')
),
dom.maybe(viewInstance.isTruncated, () =>
dom('div.viewsection_truncated', 'Not all data is shown')
),
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
viewInstance.viewPane
)
),
dom.on('mousedown', () => { this.viewModel.activeSectionId(sectionRowId); }),
);
return buildViewSectionDom(this.gristDoc, sectionRowId, this._isResizing, this.viewModel);
}
/**
@ -396,3 +354,56 @@ const cssLayoutBox = styled('div', `
const cssResizing = styled('div', `
pointer-events: none;
`);
export function buildViewSectionDom(
gristDoc: GristDoc,
sectionRowId: number,
isResizing: Observable<boolean> = Observable.create(null, false),
viewModel?: ViewRec,
) {
// Creating normal section dom
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
return dom('div.view_leaf.viewsection_content.flexvbox.flexauto',
testId(`viewlayout-section-${sectionRowId}`),
cssViewLeaf.cls(''),
cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)),
dom.cls('active_section', vs.hasFocus),
dom.maybe<BaseView|null>((use) => use(vs.viewInstance), (viewInstance) => dom('div.viewsection_title.flexhbox',
dom('span.viewsection_drag_indicator.glyphicon.glyphicon-option-vertical',
// Makes element grabbable only if grist is not readonly.
dom.cls('layout_grabbable', (use) => !use(gristDoc.isReadonlyKo))),
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
cssSigmaIcon('Pivot', testId('sigma'))),
dom('div.viewsection_titletext_container.flexitem.flexhbox',
dom('span.viewsection_titletext', editableLabel(
fromKo(vs.titleDef),
(val) => vs.titleDef.saveOnly(val),
testId('viewsection-title'),
)),
),
viewInstance.buildTitleControls(),
dom('span.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc.docModel, vs, gristDoc.isReadonly)
)
)),
dom.maybe((use) => use(vs.activeFilterBar) || use(vs.isRaw) && use(vs.activeFilters).length,
() => dom.create(filterBar, vs)),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) =>
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing),
dom.maybe(viewInstance.disableEditing, () =>
dom('div.disable_viewpane.flexvbox', 'No data')
),
dom.maybe(viewInstance.isTruncated, () =>
dom('div.viewsection_truncated', 'Not all data is shown')
),
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
viewInstance.viewPane
)
),
dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }),
);
}

View File

@ -1,5 +1,5 @@
import {KoArray} from 'app/client/lib/koArray';
import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel';
import {DocModel, IRowModel, recordSet, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
import {ColumnRec, ValidationRec, ViewRec} from 'app/client/models/DocModel';
import {MANUALSORT} from 'app/common/gristTypes';
import * as ko from 'knockout';
@ -12,6 +12,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
validations: ko.Computed<KoArray<ValidationRec>>;
primaryView: ko.Computed<ViewRec>;
rawViewSection: ko.Computed<ViewSectionRec>;
summarySource: ko.Computed<TableRec>;
// A Set object of colRefs for all summarySourceCols of table.
@ -37,6 +38,7 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
this.validations = recordSet(this, docModel.validations, 'tableRef');
this.primaryView = refRecord(docModel.views, this.primaryViewId);
this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef);
this.summarySource = refRecord(docModel.tables, this.summarySourceTable);
// A Set object of colRefs for all summarySourceCols of this table.

View File

@ -32,7 +32,9 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void {
// The default function which is used when the conditional case is true.
// Read may occur for recently disposed sections, must check condition first.
return !this.isDisposed() &&
this.viewSections().all().length > 0 ? this.viewSections().at(0)!.getRowId() : 0;
// `!this.getRowId()` implies that this is an empty (non-existent) view record
// which happens when viewing the raw data tables, in which case the default is no active view section.
this.getRowId() && this.viewSections().all().length > 0 ? this.viewSections().at(0)!.getRowId() : 0;
});
this.activeSection = refRecord(docModel.viewSections, this.activeSectionId);

View File

@ -47,6 +47,10 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
tableTitle: ko.Computed<string>;
titleDef: modelUtil.KoSaveableObservable<string>;
// true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type.
isRaw: ko.Computed<boolean>;
borderWidthPx: ko.Computed<string>;
layoutSpecObj: modelUtil.ObjObservable<any>;
@ -276,6 +280,10 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
)
);
// true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type.
this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.getRowId()));
this.borderWidthPx = ko.pureComputed(function() { return this.borderWidth() + 'px'; }, this);
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);

View File

@ -1,4 +1,5 @@
import {menuItem, menuSubHeader} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
interface IFieldOptions {
useSeparate: () => void;
@ -6,10 +7,11 @@ interface IFieldOptions {
revertToCommon: () => void;
}
export function FieldSettingsMenu(useColOptions: boolean, actions: IFieldOptions) {
export function FieldSettingsMenu(useColOptions: boolean, disableSeparate: boolean, actions: IFieldOptions) {
useColOptions = useColOptions || disableSeparate;
return [
menuSubHeader(`Using ${useColOptions ? 'common' : 'separate'} settings`),
useColOptions ? menuItem(actions.useSeparate, 'Use separate settings') : [
useColOptions ? menuItem(actions.useSeparate, 'Use separate settings', dom.cls('disabled', disableSeparate)) : [
menuItem(actions.saveAsCommon, 'Save as common settings'),
menuItem(actions.revertToCommon, 'Revert to common settings'),
]

View File

@ -39,6 +39,7 @@ export interface IMultiColumnContextMenu {
numFrozen: number;
disableModify: boolean|'mixed'; // If the columns are read-only.
isReadonly: boolean;
isRaw: boolean;
isFiltered: boolean; // If this view shows a proper subset of all rows in the table.
isFormula: boolean|'mixed';
columnIndices: number[];
@ -57,10 +58,9 @@ export function calcFieldsCondition(fields: ViewFieldRec[], condition: (f: ViewF
}
export function ColumnContextMenu(options: IColumnContextMenu) {
const { disableModify, filterOpenFunc, colId, sortSpec, isReadonly } = options;
const { disableModify, filterOpenFunc, colId, sortSpec, isReadonly, isRaw } = options;
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
@ -112,7 +112,7 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
menuItem(allCommands.sortFilterTabOpen.run, 'More sort options ...', testId('more-sort-options')),
menuDivider({style: 'margin-top: 0;'}),
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
menuItemCmd(allCommands.hideField, 'Hide column', dom.cls('disabled', isReadonly || isRaw)),
freezeMenuItemCmd(options),
menuDivider(),
MultiColumnMenu((options.disableFrozenMenu = true, options)),

View File

@ -307,8 +307,14 @@ export class RightPanel extends Disposable {
testId('right-widget-title')
)),
cssRow(primaryButton('Change Widget', this._createPageWidgetPicker()),
cssRow.cls('-top-space')),
dom.maybe(
(use) => !use(activeSection.isRaw),
() => cssRow(
primaryButton('Change Widget', this._createPageWidgetPicker()),
cssRow.cls('-top-space')
),
),
cssSeparator(),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
@ -346,7 +352,13 @@ export class RightPanel extends Disposable {
];
}),
dom.maybe((use) => !use(hasCustomMapping) && use(this._pageWidgetType) !== 'chart', () => [
dom.maybe(
(use) => !(
use(hasCustomMapping) ||
use(this._pageWidgetType) === 'chart' ||
use(activeSection.isRaw)
),
() => [
cssSeparator(),
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
]),
@ -413,6 +425,7 @@ export class RightPanel extends Disposable {
dom.hide((use) => !use(use(table).summarySourceTable)),
),
dom.maybe((use) => !use(activeSection.isRaw), () =>
cssButtonRow(primaryButton('Edit Data Selection', this._createPageWidgetPicker(),
testId('pwc-editDataSelection')),
dom.maybe(
@ -424,7 +437,7 @@ export class RightPanel extends Disposable {
testId('detach-button'),
)),
cssRow.cls('-top-space'),
),
)),
// TODO: "Advanced settings" is for "on-demand" marking of tables. This should only be shown
// for raw data tables (once that's supported), should have updated UI, and should possibly
@ -434,11 +447,13 @@ export class RightPanel extends Disposable {
)),
cssSeparator(),
dom.maybe((use) => !use(activeSection.isRaw), () => [
cssLabel('SELECT BY'),
cssRow(
select(link, linkOptions, {defaultLabel: 'Select Widget'}),
testId('right-select-by')
),
]),
domComputed((use) => {
const activeSectionRef = activeSection.getRowId();

View File

@ -1,5 +1,5 @@
import {allCommands} from 'app/client/components/commands';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {testId} from 'app/client/ui2018/cssVars';
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
@ -7,7 +7,7 @@ import {dom} from 'grainjs';
/**
* Returns a list of menu items for a view section.
*/
export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionRec, isReadonly: boolean) {
export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: boolean) {
const viewInstance = viewSection.viewInstance.peek()!;
const gristDoc = viewInstance.gristDoc;
@ -30,6 +30,7 @@ export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionR
menuDivider(),
];
const viewRec = viewSection.view();
return [
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
menuItemCmd(allCommands.printSection, 'Print widget', testId('print-section')),
@ -50,7 +51,7 @@ export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionR
testId('section-open-configuration')),
),
menuItemCmd(allCommands.deleteSection, 'Delete widget',
dom.cls('disabled', viewModel.viewSections().peekLength <= 1 || isReadonly),
dom.cls('disabled', !viewRec.getRowId() || viewRec.viewSections().peekLength <= 1 || isReadonly),
testId('section-delete')),
];
}

View File

@ -1,5 +1,5 @@
import {reportError} from 'app/client/models/AppModel';
import { ColumnRec, DocModel, ViewRec, ViewSectionRec } from 'app/client/models/DocModel';
import {ColumnRec, DocModel, ViewSectionRec} from 'app/client/models/DocModel';
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
import {CustomComputed} from 'app/client/models/modelUtil';
import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu';
@ -39,7 +39,7 @@ function doRevert(viewSection: ViewSectionRec) {
// [Filter Icon] (v) (x) - Filter toggle and all the components in the menu.
export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, viewSection: ViewSectionRec,
viewModel: ViewRec, isReadonly: Observable<boolean>) {
isReadonly: Observable<boolean>) {
const popupControls = new WeakMap<ColumnRec, PopupControl>();
@ -84,7 +84,8 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
// [+] Add filter
makeAddFilterButton(viewSection, popupControls),
// [+] Toggle filter bar
makeFilterBarToggle(viewSection.activeFilterBar),
dom.maybe((use) => !use(viewSection.isRaw),
() => makeFilterBarToggle(viewSection.activeFilterBar)),
// Widget options
dom.maybe(use => use(viewSection.parentKey) === 'custom', () =>
makeCustomOptions(viewSection)
@ -125,7 +126,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
testId('viewLayout'),
cssFixHeight.cls(''),
cssDotsIconWrapper(cssIcon('Dots')),
menu(_ctl => makeViewLayoutMenu(viewModel, viewSection, isReadonly.get()))
menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get()))
)
];
}

View File

@ -348,11 +348,15 @@ export class FieldBuilder extends Disposable {
dom('div.fieldbuilder_settings_button',
dom.testId('FieldBuilder_settings'),
kd.text(() => this.field.useColOptions() ? 'Common' : 'Separate'), ' ▾',
menu(ctl => FieldSettingsMenu(this.field.useColOptions(), {
menu(() => FieldSettingsMenu(
this.field.useColOptions(),
this.field.viewSection().isRaw(),
{
useSeparate: () => this.fieldSettingsUseSeparate(),
saveAsCommon: () => this.fieldSettingsSaveAsCommon(),
revertToCommon: () => this.fieldSettingsRevertToCommon()
}))
revertToCommon: () => this.fieldSettingsRevertToCommon(),
},
)),
),
'Field in ',
kd.text(() => this.origColumn.viewFields().all().length),

View File

@ -10,7 +10,9 @@ import {Document} from 'app/common/UserAPI';
import clone = require('lodash/clone');
import pickBy = require('lodash/pickBy');
export type IDocPage = number | 'code' | 'acl' | 'GristDocTour';
const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour');
type SpecialDocPage = typeof SpecialDocPage.type;
export type IDocPage = number | SpecialDocPage;
// What page to show in the user's home area. Defaults to 'workspace' if a workspace is set, and
// to 'all' otherwise.
@ -344,11 +346,11 @@ export function userOverrideParams(email: string|null, extraState?: IGristUrlSta
}
/**
* parseDocPage is a noop if p is 'new' or 'code', otherwise parse to integer
* parseDocPage is a noop for special pages, otherwise parse to integer
*/
function parseDocPage(p: string) {
if (['code', 'acl', 'GristDocTour'].includes(p)) {
return p as 'code'|'acl'|'GristDocTour';
function parseDocPage(p: string): IDocPage {
if (SpecialDocPage.guard(p)) {
return p;
}
return parseInt(p, 10);
}