(core) Download as CSV button on sections

Summary: Adding "Download as CSV" button that exports filtred section data to csv

Test Plan: Browser tests

Reviewers: paulfitz, dsagal

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2830
This commit is contained in:
Jarosław Sadziński
2021-05-27 13:06:26 +02:00
parent 5c0494fe29
commit 96fee73b70
14 changed files with 311 additions and 185 deletions

View File

@@ -576,13 +576,19 @@ export class GristDoc extends DisposableWithEvents {
}
public getCsvLink() {
return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams({
const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({
colRef : field.colRef.peek(),
filter : field.activeFilter.peek()
}));
const params = {
...this.docComm.getUrlParams(),
title: this.docPageModel.currentDocTitle.get(),
viewSection: this.viewModel.activeSectionId(),
tableId: this.viewModel.activeSection().table().tableId(),
activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec())
});
activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()),
filters : JSON.stringify(filters),
};
return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams(params);
}
public hasGranularAccessRules(): boolean {

View File

@@ -1,53 +1,8 @@
import {CellValue} from 'app/common/DocActions';
import {nativeCompare} from 'app/common/gutil';
import {Computed, Disposable, Observable} from 'grainjs';
export type ColumnFilterFunc = (value: CellValue) => boolean;
interface FilterSpec { // Filter object as stored in the db
included?: CellValue[];
excluded?: CellValue[];
}
// A more efficient representation of filter state for a column than FilterSpec.
interface FilterState {
include: boolean;
values: Set<CellValue>;
}
// Creates a FilterState. Accepts spec as a json string or a FilterSpec.
function makeFilterState(spec: string | FilterSpec): FilterState {
if (typeof(spec) === 'string') {
return makeFilterState((spec && JSON.parse(spec)) || {});
}
return {
include: Boolean(spec.included),
values: new Set(spec.included || spec.excluded || []),
};
}
// Returns true if state and spec are equivalent, false otherwise.
export function isEquivalentFilter(state: FilterState, spec: FilterSpec): boolean {
const other = makeFilterState(spec);
if (state.include !== other.include) { return false; }
if (state.values.size !== other.values.size) { return false; }
for (const val of other.values) { if (!state.values.has(val)) { return false; } }
return true;
}
// Returns a filter function for a particular column: the function takes a cell value and returns
// whether it's accepted according to the given FilterState.
function makeFilterFunc({include, values}: FilterState): ColumnFilterFunc {
// NOTE: This logic results in complex values and their stringified JSON representations as equivalent.
// For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same.
// TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.
return (val: CellValue) => (values.has(Array.isArray(val) ? JSON.stringify(val) : val) === include);
}
// Given a JSON string, returns a ColumnFilterFunc
export function getFilterFunc(filterJson: string): ColumnFilterFunc|null {
return filterJson ? makeFilterFunc(makeFilterState(filterJson)) : null;
}
import {ColumnFilterFunc, makeFilterFunc} from "app/common/ColumnFilterFunc";
import {FilterSpec, FilterState, makeFilterState} from "app/common/FilterState";
/**
* ColumnFilter implements a custom filter on a column, i.e. a filter that's diverged from what's

View File

@@ -28,7 +28,7 @@
*/
import * as DataTableModel from 'app/client/models/DataTableModel';
import {DocModel} from 'app/client/models/DocModel';
import {BaseFilteredRowSource, FilterFunc, RowId, RowList, RowSource} from 'app/client/models/rowset';
import {BaseFilteredRowSource, RowId, RowList, RowSource} from 'app/client/models/rowset';
import {TableData} from 'app/client/models/TableData';
import {ActiveDocAPI, Query} from 'app/common/ActiveDocAPI';
import {TableDataAction} from 'app/common/DocActions';
@@ -36,6 +36,7 @@ import {DocData} from 'app/common/DocData';
import {nativeCompare} from 'app/common/gutil';
import {IRefCountSub, RefCountMap} from 'app/common/RefCountMap';
import {TableData as BaseTableData} from 'app/common/TableData';
import {RowFilterFunc} from 'app/common/RowFilterFunc';
import {tbind} from 'app/common/tbind';
import {Disposable, Holder, IDisposableOwnerT} from 'grainjs';
import * as ko from 'knockout';
@@ -295,7 +296,7 @@ export class TableQuerySets {
/**
* Returns a filtering function which tells whether a row matches the given query.
*/
export function getFilterFunc(docData: DocData, query: Query): FilterFunc {
export function getFilterFunc(docData: DocData, query: Query): RowFilterFunc<RowId> {
// NOTE we rely without checking on tableId and colIds being valid.
const tableData: BaseTableData = docData.getTable(query.tableId)!;
const colIds = Object.keys(query.filters).sort();

View File

@@ -1,22 +1,17 @@
import {KoArray} from 'app/client/lib/koArray';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {FilterFunc, RowId} from 'app/client/models/rowset';
import {RowId} from 'app/client/models/rowset';
import {TableData} from 'app/client/models/TableData';
import {CellValue} from 'app/common/DocActions';
import {Computed, Disposable, MutableObsArray, obsArray, Observable} from 'grainjs';
import {ColumnFilter, ColumnFilterFunc, getFilterFunc} from './ColumnFilter';
type RowValueFunc = (rowId: RowId) => CellValue;
import {ColumnFilter} from './ColumnFilter';
import {buildRowFilter, RowFilterFunc, RowValueFunc } from "app/common/RowFilterFunc";
import {buildColFilter} from "app/common/ColumnFilterFunc";
interface OpenColumnFilter {
fieldRef: number;
colFilter: ColumnFilter;
}
function buildColFunc(getter: RowValueFunc, filterFunc: ColumnFilterFunc): FilterFunc {
return (rowId: RowId) => filterFunc(getter(rowId));
}
/**
* SectionFilter represents a collection of column filters in place for a view section. It is created
* out of `viewFields` and `tableData`, and provides a Computed `sectionFilterFunc` that users can
@@ -28,7 +23,7 @@ function buildColFunc(getter: RowValueFunc, filterFunc: ColumnFilterFunc): Filte
* results in their being displayed (obviating the need to maintain their rowId explicitly).
*/
export class SectionFilter extends Disposable {
public readonly sectionFilterFunc: Observable<FilterFunc>;
public readonly sectionFilterFunc: Observable<RowFilterFunc<RowId>>;
private _openFilterOverride: Observable<OpenColumnFilter|null> = Observable.create(this, null);
private _tempRows: MutableObsArray<RowId> = obsArray();
@@ -38,16 +33,16 @@ export class SectionFilter extends Disposable {
const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {
const fields = use(use(viewFields).getObservable());
const funcs: Array<FilterFunc | null> = fields.map(f => {
const funcs: Array<RowFilterFunc<RowId> | null> = fields.map(f => {
const filterFunc = (openFilter && openFilter.fieldRef === f.getRowId()) ?
use(openFilter.colFilter.filterFunc) :
getFilterFunc(use(f.activeFilter));
buildColFilter(use(f.activeFilter));
const getter = tableData.getRowPropFunc(use(f.colId));
if (!filterFunc || !getter) { return null; }
return buildColFunc(getter as RowValueFunc, filterFunc);
return buildRowFilter(getter as RowValueFunc<RowId>, filterFunc);
})
.filter(f => f !== null); // Filter out columns that don't have a filter

View File

@@ -25,6 +25,7 @@ import koArray, {KoArray} from 'app/client/lib/koArray';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {CompareFunc, sortedIndex} from 'app/common/gutil';
import {SkippableRows} from 'app/common/TableData';
import {RowFilterFunc} from "app/common/RowFilterFunc";
/**
* Special constant value that can be used for the `rows` array for the 'rowNotify'
@@ -206,8 +207,6 @@ export class ExtendedRowSource extends RowSource {
// FilteredRowSource
// ----------------------------------------------------------------------
export type FilterFunc = (row: RowId) => boolean;
interface FilterRowChanges {
adds?: RowId[];
updates?: RowId[];
@@ -221,7 +220,7 @@ interface FilterRowChanges {
export class BaseFilteredRowSource extends RowListener implements RowSource {
protected _matchingRows: Set<RowId> = new Set(); // Set of rows matching the filter.
constructor(protected _filterFunc: FilterFunc) {
constructor(protected _filterFunc: RowFilterFunc<RowId>) {
super();
}
@@ -327,7 +326,7 @@ export class FilteredRowSource extends BaseFilteredRowSource {
* Change the filter function. This may trigger 'remove' and 'add' events as necessary to indicate
* that rows stopped or started matching the new filter.
*/
public updateFilter(filterFunc: FilterFunc) {
public updateFilter(filterFunc: RowFilterFunc<RowId>) {
this._filterFunc = filterFunc;
const changes: FilterRowChanges = {};
// After the first call, _excludedRows may have additional rows, but there is no harm in it,

View File

@@ -4,7 +4,7 @@
* but on Cancel the model is reset to its initial state prior to menu closing.
*/
import {allInclusive, ColumnFilter, isEquivalentFilter} from 'app/client/models/ColumnFilter';
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter';
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {FilteredRowSource} from 'app/client/models/rowset';
@@ -20,6 +20,7 @@ import {Computed, Disposable, dom, DomElementMethod, IDisposableOwner, input, ma
import identity = require('lodash/identity');
import noop = require('lodash/noop');
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
import {isEquivalentFilter} from "app/common/FilterState";
interface IFilterMenuOptions {
model: ColumnFilterMenuModel;

View File

@@ -1,15 +1,18 @@
import {allCommands} from 'app/client/components/commands';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {testId} from 'app/client/ui2018/cssVars';
import {menuDivider, menuItemCmd} from 'app/client/ui2018/menus';
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
/**
* Returns a list of menu items for a view section.
*/
export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionRec, isReadonly: boolean) {
const gristDoc = viewSection.viewInstance.peek()!.gristDoc;
return [
menuItemCmd(allCommands.printSection, 'Print widget', testId('print-section')),
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
'Download as CSV', testId('download-section')),
dom.maybe((use) => ['detail', 'single'].includes(use(viewSection.parentKey)), () =>
menuItemCmd(allCommands.editLayout, 'Edit Card Layout',
dom.cls('disabled', isReadonly))),