(core) updates from grist-core

pull/632/head
Paul Fitzpatrick 9 months ago
commit f1a0b61e15

@ -316,6 +316,20 @@ GridView.gridCommands = {
this._shiftSelect(-1, this.cellSelector.col.end, selector.ROW,
this.viewSection.viewFields().peekLength - 1);
},
ctrlShiftDown: function () {
this._shiftSelectUntilContent(selector.COL, 1, this.cellSelector.row.end, this.getLastDataRowIndex());
},
ctrlShiftUp: function () {
this._shiftSelectUntilContent(selector.COL, -1, this.cellSelector.row.end, this.getLastDataRowIndex());
},
ctrlShiftRight: function () {
this._shiftSelectUntilContent(selector.ROW, 1, this.cellSelector.col.end,
this.viewSection.viewFields().peekLength - 1);
},
ctrlShiftLeft: function () {
this._shiftSelectUntilContent(selector.ROW, -1, this.cellSelector.col.end,
this.viewSection.viewFields().peekLength - 1);
},
fillSelectionDown: function() { this.fillSelectionDown(); },
selectAll: function() { this.selectAll(); },
@ -403,6 +417,120 @@ GridView.prototype._shiftSelect = function(step, selectObs, exemptType, maxVal)
selectObs(newVal);
};
GridView.prototype._shiftSelectUntilContent = function(type, direction, selectObs, maxVal) {
const selection = {
colStart: this.cellSelector.col.start(),
colEnd: this.cellSelector.col.end(),
rowStart: this.cellSelector.row.start(),
rowEnd: this.cellSelector.row.end(),
};
const steps = this._stepsToContent(type, direction, selection, maxVal);
if (steps > 0) { this._shiftSelect(direction * steps, selectObs, type, maxVal); }
}
GridView.prototype._stepsToContent = function (type, direction, selection, maxVal) {
const {colEnd: colEnd, rowEnd: rowEnd} = selection;
let selectionData;
const cursorCol = this.cursor.fieldIndex();
const cursorRow = this.cursor.rowIndex();
if (type === selector.ROW && direction > 0) {
if (colEnd + 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: colEnd, colEnd: maxVal, rowStart: cursorRow, rowEnd: cursorRow});
} else if (type === selector.ROW && direction < 0) {
if (colEnd - 1 < 0) { return 0; }
selectionData = this._selectionData({colStart: 0, colEnd, rowStart: cursorRow, rowEnd: cursorRow});
} else if (type === selector.COL && direction > 0) {
if (rowEnd + 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: rowEnd, rowEnd: maxVal});
} else if (type === selector.COL && direction < 0) {
if (rowEnd - 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: 0, rowEnd});
}
const {fields, rowIndices} = selectionData;
if (type === selector.ROW && direction < 0) {
// When moving selection left, we step through fields in reverse order.
fields.reverse();
}
if (type === selector.COL && direction < 0) {
// When moving selection up, we step through rows in reverse order.
rowIndices.reverse();
}
const colValuesByIndex = {};
for (const field of fields) {
const displayColId = field.displayColModel.peek().colId.peek();
colValuesByIndex[field._index()] = this.tableModel.tableData.getColValues(displayColId);
}
let steps = 0;
if (type === selector.ROW) {
const rowIndex = rowIndices[0];
const isLastColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd][rowIndex]);
const isNextColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd + direction][rowIndex]);
const shouldStopOnEmptyValue = !isLastColEmpty && !isNextColEmpty;
for (let i = 1; i < fields.length; i++) {
const hasEmptyValues = this._isCellValueEmpty(colValuesByIndex[fields[i]._index()][rowIndex]);
if (hasEmptyValues && shouldStopOnEmptyValue) {
return steps;
} else if (!hasEmptyValues && !shouldStopOnEmptyValue) {
return steps + 1;
}
steps += 1;
}
} else {
const colValues = colValuesByIndex[fields[0]._index()];
const isLastRowEmpty = this._isCellValueEmpty(colValues[rowIndices[0]]);
const isNextRowEmpty = this._isCellValueEmpty(colValues[rowIndices[1]]);
const shouldStopOnEmptyValue = !isLastRowEmpty && !isNextRowEmpty;
for (let i = 1; i < rowIndices.length; i++) {
const hasEmptyValues = this._isCellValueEmpty(colValues[rowIndices[i]]);
if (hasEmptyValues && shouldStopOnEmptyValue) {
return steps;
} else if (!hasEmptyValues && !shouldStopOnEmptyValue) {
return steps + 1;
}
steps += 1;
}
}
return steps;
}
GridView.prototype._selectionData = function({colStart, colEnd, rowStart, rowEnd}) {
const fields = [];
for (let i = colStart; i <= colEnd; i++) {
const field = this.viewSection.viewFields().at(i);
if (!field) { continue; }
fields.push(field);
}
const rowIndices = [];
for (let i = rowStart; i <= rowEnd; i++) {
const rowId = this.viewData.getRowId(i);
if (!rowId) { continue; }
rowIndices.push(this.tableModel.tableData.getRowIdIndex(rowId));
}
return {fields, rowIndices};
}
GridView.prototype._isCellValueEmpty = function(value) {
return value === null || value === undefined || value === '' || value === 'false';
}
/**
* Pastes the provided data at the current cursor.
*
@ -532,15 +660,15 @@ GridView.prototype.fillSelectionDown = function() {
/**
* Returns a GridSelection of the selected rows and cols
* Returns a CopySelection of the selected rows and cols
* @returns {Object} CopySelection
*/
GridView.prototype.getSelection = function() {
var rowIds = [], fields = [], rowStyle = {}, colStyle = {};
var colStart = this.cellSelector.colLower();
var colEnd = this.cellSelector.colUpper();
var rowStart = this.cellSelector.rowLower();
var rowEnd = this.cellSelector.rowUpper();
let rowIds = [], fields = [], rowStyle = {}, colStyle = {};
let colStart = this.cellSelector.colLower();
let colEnd = this.cellSelector.colUpper();
let rowStart = this.cellSelector.rowLower();
let rowEnd = this.cellSelector.rowUpper();
// If there is no selection, just copy/paste the cursor cell
if (this.cellSelector.isCurrentSelectType(selector.NONE)) {

@ -50,6 +50,10 @@ export type CommandName =
| 'shiftUp'
| 'shiftRight'
| 'shiftLeft'
| 'ctrlShiftDown'
| 'ctrlShiftUp'
| 'ctrlShiftRight'
| 'ctrlShiftLeft'
| 'selectAll'
| 'copyLink'
| 'editField'
@ -373,6 +377,22 @@ export const groups: CommendGroupDef[] = [{
name: 'shiftLeft',
keys: ['Shift+Left'],
desc: 'Adds the element to the left of the cursor to the selected range'
}, {
name: 'ctrlShiftDown',
keys: ['Mod+Shift+Down'],
desc: 'Adds all elements below the cursor to the selected range'
}, {
name: 'ctrlShiftUp',
keys: ['Mod+Shift+Up'],
desc: 'Adds all elements above the cursor to the selected range'
}, {
name: 'ctrlShiftRight',
keys: ['Mod+Shift+Right'],
desc: 'Adds all elements to the right of the cursor to the selected range'
}, {
name: 'ctrlShiftLeft',
keys: ['Mod+Shift+Left'],
desc: 'Adds all elements to the left of the cursor to the selected range'
}, {
name: 'selectAll',
keys: ['Mod+A'],

@ -68,6 +68,10 @@ export const ColumnsPatch = t.iface([], {
"columns": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))),
});
export const ColumnsPut = t.iface([], {
"columns": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))),
});
export const TablePost = t.iface(["ColumnsPost"], {
"id": t.opt("string"),
});
@ -93,6 +97,7 @@ const exportedTypeSuite: t.ITypeSuite = {
MinimalRecord,
ColumnsPost,
ColumnsPatch,
ColumnsPut,
TablePost,
TablesPost,
TablesPatch,

@ -88,6 +88,10 @@ export interface ColumnsPatch {
columns: [RecordWithStringId, ...RecordWithStringId[]]; // at least one column is required
}
export interface ColumnsPut {
columns: [RecordWithStringId, ...RecordWithStringId[]]; // at least one column is required
}
/**
* Creating tables requires a list of columns.
* `fields` is not accepted because it's not generally sensible to set the metadata fields on new tables.

@ -2,7 +2,14 @@ import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer
import {createEmptyActionSummary} from "app/common/ActionSummary";
import {ApiError, LimitType} from 'app/common/ApiError';
import {BrowserSettings} from "app/common/BrowserSettings";
import {BulkColValues, ColValues, fromTableDataAction, TableColValues, TableRecordValue} from 'app/common/DocActions';
import {
BulkColValues,
ColValues,
fromTableDataAction,
TableColValues,
TableRecordValue,
UserAction
} from 'app/common/DocActions';
import {isRaisedException} from "app/common/gristTypes";
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
import {isAffirmative} from "app/common/gutil";
@ -94,7 +101,7 @@ type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Respon
// Schema validators for api endpoints that creates or updates records.
const {
RecordsPatch, RecordsPost, RecordsPut,
ColumnsPost, ColumnsPatch,
ColumnsPost, ColumnsPatch, ColumnsPut,
TablesPost, TablesPatch,
} = t.createCheckers(DocApiTypesTI, GristDataTI);
@ -194,16 +201,23 @@ export class DocWorkerApi {
}
async function getTableRecords(
activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string
activeDoc: ActiveDoc, req: RequestWithLogin, opts?: { optTableId?: string; includeHidden?: boolean }
): Promise<TableRecordValue[]> {
const columnData = await getTableData(activeDoc, req, optTableId);
const fieldNames = Object.keys(columnData)
.filter(k => !(
["id", "manualSort"].includes(k)
|| k.startsWith("gristHelper_")
));
const columnData = await getTableData(activeDoc, req, opts?.optTableId);
const fieldNames = Object.keys(columnData).filter((k) => {
if (k === "id") {
return false;
}
if (
!opts?.includeHidden &&
(k === "manualSort" || k.startsWith("gristHelper_"))
) {
return false;
}
return true;
});
return columnData.id.map((id, index) => {
const result: TableRecordValue = {id, fields: {}};
const result: TableRecordValue = { id, fields: {} };
for (const key of fieldNames) {
let value = columnData[key][index];
if (isRaisedException(value)) {
@ -226,7 +240,9 @@ export class DocWorkerApi {
// Get the specified table in record-oriented format
this._app.get('/api/docs/:docId/tables/:tableId/records', canView,
withDoc(async (activeDoc, req, res) => {
const records = await getTableRecords(activeDoc, req);
const records = await getTableRecords(activeDoc, req,
{ includeHidden: isAffirmative(req.query.hidden) }
);
res.json({records});
})
);
@ -357,8 +373,9 @@ export class DocWorkerApi {
this._app.get('/api/docs/:docId/tables/:tableId/columns', canView,
withDoc(async (activeDoc, req, res) => {
const tableId = req.params.tableId;
const includeHidden = isAffirmative(req.query.hidden);
const columns = await handleSandboxError('', [],
activeDoc.getTableCols(docSessionFromRequest(req), tableId));
activeDoc.getTableCols(docSessionFromRequest(req), tableId, includeHidden));
res.json({columns});
})
);
@ -366,7 +383,7 @@ export class DocWorkerApi {
// Get the tables of the specified document in recordish format
this._app.get('/api/docs/:docId/tables', canView,
withDoc(async (activeDoc, req, res) => {
const records = await getTableRecords(activeDoc, req, "_grist_Tables");
const records = await getTableRecords(activeDoc, req, { optTableId: "_grist_Tables" });
const tables = records.map((record) => ({
id: record.fields.tableId,
fields: {
@ -395,7 +412,7 @@ export class DocWorkerApi {
// Returns cleaned metadata for all attachments in /records format.
this._app.get('/api/docs/:docId/attachments', canView, withDoc(async (activeDoc, req, res) => {
const rawRecords = await getTableRecords(activeDoc, req, "_grist_Attachments");
const rawRecords = await getTableRecords(activeDoc, req, { optTableId: "_grist_Attachments" });
const records = rawRecords.map(r => ({
id: r.id,
fields: cleanAttachmentRecord(r.fields as MetaRowRecord<"_grist_Attachments">),
@ -681,6 +698,54 @@ export class DocWorkerApi {
})
);
// Add or update records given in records format
this._app.put('/api/docs/:docId/tables/:tableId/columns', canEdit, validate(ColumnsPut),
withDoc(async (activeDoc, req, res) => {
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const columnsTable = activeDoc.docData!.getMetaTable("_grist_Tables_column");
const {tableId} = req.params;
const tableRef = tablesTable.findMatchingRowId({tableId});
if (!tableRef) {
throw new ApiError(`Table not found "${tableId}"`, 404);
}
const body = req.body as Types.ColumnsPut;
const addActions: UserAction[] = [];
const updateActions: UserAction[] = [];
const updatedColumnsIds = new Set();
for (const col of body.columns) {
const id = columnsTable.findMatchingRowId({parentId: tableRef, colId: col.id});
if (id) {
updateActions.push( ['UpdateRecord', '_grist_Tables_column', id, col.fields] );
updatedColumnsIds.add( id );
} else {
addActions.push( ['AddVisibleColumn', tableId, col.id, col.fields] );
}
}
const getRemoveAction = async () => {
const columns = await handleSandboxError('', [],
activeDoc.getTableCols(docSessionFromRequest(req), tableId));
const columnsToRemove = columns
.map(col => col.fields.colRef as number)
.filter(colRef => !updatedColumnsIds.has(colRef));
return [ 'BulkRemoveRecord', '_grist_Tables_column', columnsToRemove ];
};
const actions = [
...(!isAffirmative(req.query.noupdate) ? updateActions : []),
...(!isAffirmative(req.query.noadd) ? addActions : []),
...(isAffirmative(req.query.replaceall) ? [ await getRemoveAction() ] : [] )
];
await handleSandboxError(tableId, [],
activeDoc.applyUserActions(docSessionFromRequest(req), actions)
);
res.json(null);
})
);
// Add a new webhook and trigger
this._app.post('/api/docs/:docId/webhooks', isOwner, validate(WebhookSubscribeCollection),
withDoc(async (activeDoc, req, res) => {

@ -483,7 +483,18 @@
"{{count}} unmatched field in import_other": "{{count}} unangepasste Felder im Import",
"{{count}} unmatched field_one": "{{count}} unangepasstes Feld",
"{{count}} unmatched field_other": "{{count}} unangepasste Felder",
"{{count}} unmatched field in import_one": "{{count}} unangepasst Feld im Import"
"{{count}} unmatched field in import_one": "{{count}} unangepasst Feld im Import",
"Column mapping": "Spaltenzuordnung",
"Column Mapping": "Spaltenzuordnung",
"Destination table": "Zieltabelle",
"Grist column": "Grist Spalte",
"Import from file": "Aus Datei importieren",
"New Table": "Neue Tabelle",
"Revert": "Zurücksetzen",
"Skip": "überspringen",
"Skip Import": "Import überspringen",
"Skip Table on Import": "Tabelle beim Import überspringen",
"Source column": "Quellenspalte"
},
"LeftPanelCommon": {
"Help Center": "Hilfe-Center"
@ -904,7 +915,10 @@
"Open row styles": "Zeilenstile öffnen",
"Cell Style": "Zellenstil",
"Default cell style": "Standard-Zellenstil",
"Mixed style": "Gemischter Stil"
"Mixed style": "Gemischter Stil",
"Default header style": "Standard Kopfzeilenstil",
"Header Style": "Kopfzeilenstil",
"HEADER STYLE": "KOPFSTIL"
},
"DiscussionEditor": {
"Resolve": "Beschließen",
@ -1127,7 +1141,8 @@
"Sign Up for Free": "Melden Sie sich kostenlos an",
"Sign up for a free Grist account to start using the Formula AI Assistant.": "Melden Sie sich für ein kostenloses Grist-Konto an, um den KI Formel Assistenten zu verwenden.",
"There are some things you should know when working with me:": "Es gibt einige Dinge, die Sie wissen sollten, wenn Sie mit mir arbeiten:",
"What do you need help with?": "Womit brauchen Sie Hilfe?"
"What do you need help with?": "Womit brauchen Sie Hilfe?",
"Formula AI Assistant is only available for logged in users.": "Der Formel-KI-Assistent ist nur für eingeloggte Benutzer verfügbar."
},
"GridView": {
"Click to insert": "Zum Einfügen klicken"

@ -853,7 +853,8 @@
"Mixed style": "Mixed style",
"Open row styles": "Open row styles",
"Default header style": "Default header style",
"Header Style": "Header Style"
"Header Style": "Header Style",
"HEADER STYLE": "HEADER STYLE"
},
"ChoiceTextBox": {
"CHOICES": "CHOICES"
@ -1076,7 +1077,8 @@
"Sign Up for Free": "Sign Up for Free",
"Sign up for a free Grist account to start using the Formula AI Assistant.": "Sign up for a free Grist account to start using the Formula AI Assistant.",
"There are some things you should know when working with me:": "There are some things you should know when working with me:",
"What do you need help with?": "What do you need help with?"
"What do you need help with?": "What do you need help with?",
"Formula AI Assistant is only available for logged in users.": "Formula AI Assistant is only available for logged in users."
},
"GridView": {
"Click to insert": "Click to insert"

@ -776,7 +776,7 @@
"Unmark On-Demand": "Desmarcar bajo demanda"
},
"FilterBar": {
"SearchColumns": "Buscar columnas",
"SearchColumns": "Buscar en columnas",
"Search Columns": "Buscar columnas"
},
"Importer": {
@ -786,7 +786,18 @@
"{{count}} unmatched field in import_one": "{{count}} campo no coincide con la importación",
"{{count}} unmatched field_one": "{{count}} campo sin equivalente",
"{{count}} unmatched field_other": "{{count}} campos sin equivalentes",
"{{count}} unmatched field in import_other": "{{count}} campos sin equivalente en la importación"
"{{count}} unmatched field in import_other": "{{count}} campos sin equivalente en la importación",
"Column Mapping": "Asignación de columnas",
"Column mapping": "Asignación de las columnas",
"Import from file": "Importar desde un archivo",
"New Table": "Tabla nueva",
"Revert": "Revertir",
"Skip": "Omitir",
"Skip Import": "Omitir la importación",
"Source column": "Columna de origen",
"Destination table": "Tabla de destino",
"Grist column": "Columna Grist",
"Skip Table on Import": "Omitir la tabla en la importación"
},
"PermissionsWidget": {
"Allow All": "Permitir todo",
@ -894,7 +905,10 @@
"Default cell style": "Estilo de celda predeterminado",
"CELL STYLE": "ESTILO DE CELDA",
"Open row styles": "Abrir estilos de fila",
"Mixed style": "Estilo mixto"
"Mixed style": "Estilo mixto",
"Header Style": "Estilo del encabezado",
"Default header style": "Estilo del encabezado por defecto",
"HEADER STYLE": "ESTILO DEL ENCABEZADO"
},
"ColumnInfo": {
"Cancel": "Cancelar",
@ -1117,7 +1131,8 @@
"Hi, I'm the Grist Formula AI Assistant.": "Hola, soy el Asistente de IA de Fórmula Grist.",
"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.": "Sólo puedo ayudar con fórmulas. No puedo construir tablas, columnas y vistas, ni escribir reglas de acceso.",
"Sign Up for Free": "Regístrate gratis",
"There are some things you should know when working with me:": "Hay algunas cosas que debes saber cuando trabajes conmigo:"
"There are some things you should know when working with me:": "Hay algunas cosas que debes saber cuando trabajes conmigo:",
"Formula AI Assistant is only available for logged in users.": "Formula AI Assistant sólo está disponible para usuarios registrados."
},
"GridView": {
"Click to insert": "Haga clic para insertar"

@ -483,7 +483,18 @@
"{{count}} unmatched field_other": "{{count}} campos sem equivalente",
"{{count}} unmatched field in import_other": "{{count}} campos sem equivalente na importação",
"{{count}} unmatched field in import_one": "{{count}} campo sem equivalente na importação",
"{{count}} unmatched field_one": "{{count}} campo sem equivalente"
"{{count}} unmatched field_one": "{{count}} campo sem equivalente",
"Column Mapping": "Mapeamento de coluna",
"Column mapping": "Mapeamento de coluna",
"Destination table": "Tabela de destino",
"Grist column": "Coluna de Grist",
"New Table": "Nova tabela",
"Revert": "Reverter",
"Skip": "Pular",
"Skip Import": "Pular importação",
"Source column": "Coluna de origem",
"Import from file": "Importar de arquivo",
"Skip Table on Import": "Pular tabela na importação"
},
"LeftPanelCommon": {
"Help Center": "Centro de Ajuda"
@ -996,7 +1007,10 @@
"Cell Style": "Estilo de célula",
"Default cell style": "Estilo de célula padrão",
"Mixed style": "Estilo misto",
"Open row styles": "Estilos de linha aberta"
"Open row styles": "Estilos de linha aberta",
"Default header style": "Estilo de cabeçalho padrão",
"Header Style": "Estilo de cabeçalho",
"HEADER STYLE": "ESTILO DE CABEÇALHO"
},
"ColumnEditor": {
"COLUMN DESCRIPTION": "DESCRIÇÃO DA COLUNA",
@ -1127,7 +1141,8 @@
"Sign up for a free Grist account to start using the Formula AI Assistant.": "Cadastre-se para uma conta gratuita do Grist para começar a usar o Assistente de Fórmula AI.",
"What do you need help with?": "Em que você precisa de ajuda?",
"There are some things you should know when working with me:": "Há algumas coisas que você deve saber ao trabalhar comigo:",
"Learn more": "Saiba mais"
"Learn more": "Saiba mais",
"Formula AI Assistant is only available for logged in users.": "O Assistente de Fórmula de IA só está disponível para usuários registrados."
},
"GridView": {
"Click to insert": "Clique para inserir"

@ -549,7 +549,18 @@
"{{count}} unmatched field in import_other": "{{count}} несовпадающие поля при импорте",
"{{count}} unmatched field in import_one": "{{count}} несовпадающее поле при импорте",
"{{count}} unmatched field_one": "{{count}} несовпадающее поле",
"{{count}} unmatched field_other": "{{count}} несовпадающие поля"
"{{count}} unmatched field_other": "{{count}} несовпадающие поля",
"Column Mapping": "Сопоставление столбцов",
"Column mapping": "Сопоставление столбцов",
"Destination table": "Таблица назначения",
"Grist column": "Grist столбец",
"Import from file": "Импорт из файла",
"New Table": "Новая таблица",
"Revert": "Восстановить",
"Skip": "Пропустить",
"Skip Table on Import": "Пропустить Таблицу при импорте",
"Source column": "Столбец Источника",
"Skip Import": "Пропустить импорт"
},
"LeftPanelCommon": {
"Help Center": "Справочный центр"
@ -757,7 +768,10 @@
"Default cell style": "Стиль ячейки по умолчанию",
"Open row styles": "Открыть форматы строк",
"Cell Style": "Стиль ячейки",
"Mixed style": "Смешанный стиль"
"Mixed style": "Смешанный стиль",
"Default header style": "Стиль заголовка по умолчанию",
"HEADER STYLE": "СТИЛЬ ЗАГОЛОВКА",
"Header Style": "Стиль заголовка"
},
"TypeTransform": {
"Cancel": "Отмена",
@ -1063,7 +1077,8 @@
"Sign up for a free Grist account to start using the Formula AI Assistant.": "Зарегистрируйте бесплатную учетную запись Grist, чтобы начать использовать AI Помощника по формулам.",
"There are some things you should know when working with me:": "Есть некоторые вещи, которые вы должны знать, работая со мной:",
"What do you need help with?": "Как я могу вам помочь?",
"Hi, I'm the Grist Formula AI Assistant.": "Привет, я AI Помощник по формулам Grist."
"Hi, I'm the Grist Formula AI Assistant.": "Привет, я AI Помощник по формулам Grist.",
"Formula AI Assistant is only available for logged in users.": "AI Ассистент для формул доступен только для зарегистрированных пользователей."
},
"GridView": {
"Click to insert": "Нажмите для вставки"

Binary file not shown.

@ -0,0 +1,213 @@
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
interface CellSelection {
/** 0-based column index. */
colStart: number;
/** 0-based column index. */
colEnd: number;
/** 0-based row index. */
rowStart: number;
/** 0-based row index. */
rowEnd: number;
}
interface SelectionRange {
/** 0-based index. */
start: number;
/** 0-based index. */
end: number;
}
describe('ShiftSelection', function () {
this.timeout(20000);
const cleanup = setupTestSuite();
gu.bigScreen();
before(async function() {
const session = await gu.session().personalSite.login();
await session.tempDoc(cleanup, 'ShiftSelection.grist');
});
async function getSelectionRange(parent: WebElement, selector: string): Promise<SelectionRange|undefined> {
let start, end;
let selectionActive = false;
const elements = await parent.findAll(selector);
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
const isSelected = await element.matches('.selected');
if (isSelected && !selectionActive) {
start = i;
selectionActive = true;
continue;
}
if (!isSelected && selectionActive) {
end = i - 1;
break;
}
}
if (start === undefined) {
return undefined;
}
if (end === undefined) {
end = elements.length - 1;
}
return {
start: start,
end: end,
};
}
async function getSelectedCells(): Promise<CellSelection|undefined> {
const activeSection = await driver.find('.active_section');
const colSelection = await getSelectionRange(activeSection, '.column_names .column_name');
if (!colSelection) {
return undefined;
}
const rowSelection = await getSelectionRange(activeSection, '.gridview_row .gridview_data_row_num');
if (!rowSelection) {
// Edge case if only a cell in the "new" row is selected
// Not relevant for our tests
return undefined;
}
return {
colStart: colSelection.start,
colEnd: colSelection.end,
rowStart: rowSelection.start,
rowEnd: rowSelection.end,
};
}
async function assertCellSelection(expected: CellSelection|undefined) {
const currentSelection = await getSelectedCells();
assert.deepEqual(currentSelection, expected);
}
it('Shift+Up extends the selection up', async function () {
await gu.getCell(1, 2).click();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP));
await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 0, rowEnd: 1});
});
it('Shift+Down extends the selection down', async function () {
await gu.getCell(1, 2).click();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN));
await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 2});
});
it('Shift+Left extends the selection left', async function () {
await gu.getCell(1, 2).click();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));
await assertCellSelection({colStart: 0, colEnd: 1, rowStart: 1, rowEnd: 1});
});
it('Shift+Right extends the selection right', async function () {
await gu.getCell(1, 2).click();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));
await assertCellSelection({colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 1});
});
it('Shift+Right + Shift+Left leads to the initial selection', async function () {
await gu.getCell(1, 2).click();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));
await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));
await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1});
});
it('Shift+Up + Shift+Down leads to the initial selection', async function () {
await gu.getCell(1, 2).click();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP));
await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN));
await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1});
});
it('Ctrl+Shift+Up extends the selection blockwise up', async function () {
await gu.getCell(5, 7).click();
const ctrlKey = await gu.modKey();
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));
await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));
await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 2, rowEnd: 6});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));
await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 0, rowEnd: 6});
});
it('Ctrl+Shift+Down extends the selection blockwise down', async function () {
await gu.getCell(5, 5).click();
const ctrlKey = await gu.modKey();
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN));
await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN));
await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 8});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN));
await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 10});
});
it('Ctrl+Shift+Left extends the selection blockwise left', async function () {
await gu.getCell(6, 5).click();
const ctrlKey = await gu.modKey();
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT));
await assertCellSelection({colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT));
await assertCellSelection({colStart: 2, colEnd: 6, rowStart: 4, rowEnd: 4});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT));
await assertCellSelection({colStart: 0, colEnd: 6, rowStart: 4, rowEnd: 4});
});
it('Ctrl+Shift+Right extends the selection blockwise right', async function () {
await gu.getCell(4, 5).click();
const ctrlKey = await gu.modKey();
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT));
await assertCellSelection({colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT));
await assertCellSelection({colStart: 4, colEnd: 8, rowStart: 4, rowEnd: 4});
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT));
await assertCellSelection({colStart: 4, colEnd: 10, rowStart: 4, rowEnd: 4});
});
it('Ctrl+Shift+* extends the selection until all the next cells are empty', async function () {
await gu.getCell(3, 7).click();
const ctrlKey = await gu.modKey();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));
await assertCellSelection({colStart: 3, colEnd: 4, rowStart: 2, rowEnd: 6});
await gu.getCell(4, 7).click();
await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));
await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));
await assertCellSelection({colStart: 3, colEnd: 4, rowStart: 4, rowEnd: 6});
});
});

@ -5,7 +5,7 @@ import {arrayRepeat} from 'app/common/gutil';
import {WebhookSummary} from 'app/common/Triggers';
import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI';
import {testDailyApiLimitFeatures} from 'app/gen-server/entity/Product';
import {AddOrUpdateRecord, Record as ApiRecord} from 'app/plugin/DocApiTypes';
import {AddOrUpdateRecord, Record as ApiRecord, ColumnsPut, RecordWithStringId} from 'app/plugin/DocApiTypes';
import {CellValue, GristObjCode} from 'app/plugin/GristData';
import {
applyQueryParameters,
@ -443,6 +443,26 @@ function testDocApi() {
});
});
it('GET /docs/{did}/tables/{tid}/records honors the "hidden" param', async function () {
const params = { hidden: true };
const resp = await axios.get(
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/records`,
{...chimpy, params }
);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data.records[0], {
id: 1,
fields: {
manualSort: 1,
A: 'hello',
B: '',
C: '',
D: null,
E: 'HELLO',
},
});
});
it("GET /docs/{did}/tables/{tid}/records handles errors and hidden columns", async function () {
let resp = await axios.get(`${serverUrl}/api/docs/${docIds.ApiDataRecordsTest}/tables/Table1/records`, chimpy);
assert.equal(resp.status, 200);
@ -610,6 +630,21 @@ function testDocApi() {
);
});
it('GET /docs/{did}/tables/{tid}/columns retrieves hidden columns when "hidden" is set', async function () {
const params = { hidden: true };
const resp = await axios.get(
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/columns`,
{ ...chimpy, params }
);
assert.equal(resp.status, 200);
const columnsMap = new Map(resp.data.columns.map(({id, fields}: {id: string, fields: object}) => [id, fields]));
assert.include([...columnsMap.keys()], "manualSort");
assert.deepInclude(columnsMap.get("manualSort"), {
colRef: 1,
type: 'ManualSortPos',
});
});
it("GET/POST/PATCH /docs/{did}/tables and /columns", async function () {
// POST /tables: Create new tables
let resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables`, {
@ -842,6 +877,126 @@ function testDocApi() {
assert.equal(resp.status, 200);
});
describe("PUT /docs/{did}/columns", function () {
async function generateDocAndUrl() {
const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
const docId = await userApi.newDoc({name: 'ColumnsPut'}, wid);
const url = `${serverUrl}/api/docs/${docId}/tables/Table1/columns`;
return { url, docId };
}
async function getColumnFieldsMapById(url: string, params: any) {
const result = await axios.get(url, {...chimpy, params});
assert.equal(result.status, 200);
return new Map<string, object>(
result.data.columns.map(
({id, fields}: {id: string, fields: object}) => [id, fields]
)
);
}
async function checkPut(
columns: [RecordWithStringId, ...RecordWithStringId[]],
params: Record<string, any>,
expectedFieldsByColId: Record<string, object>,
opts?: { getParams?: any }
) {
const {url} = await generateDocAndUrl();
const body: ColumnsPut = { columns };
const resp = await axios.put(url, body, {...chimpy, params});
assert.equal(resp.status, 200);
const fieldsByColId = await getColumnFieldsMapById(url, opts?.getParams);
assert.deepEqual(
[...fieldsByColId.keys()],
Object.keys(expectedFieldsByColId),
"The updated table should have the expected columns"
);
for (const [colId, expectedFields] of Object.entries(expectedFieldsByColId)) {
assert.deepInclude(fieldsByColId.get(colId), expectedFields);
}
}
const COLUMN_TO_ADD = {
id: "Foo",
fields: {
type: "Text",
label: "FooLabel",
}
};
const COLUMN_TO_UPDATE = {
id: "A",
fields: {
type: "Numeric",
colId: "NewA"
}
};
it('should create new columns', async function () {
await checkPut([COLUMN_TO_ADD], {}, {
A: {}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields
});
});
it('should update existing columns and create new ones', async function () {
await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {}, {
NewA: {type: "Numeric", label: "A"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields
});
});
it('should only update existing columns when noadd is set', async function () {
await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noadd: "1"}, {
NewA: {type: "Numeric"}, B: {}, C: {}
});
});
it('should only add columns when noupdate is set', async function () {
await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noupdate: "1"}, {
A: {type: "Any"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields
});
});
it('should remove existing columns if replaceall is set', async function () {
await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, {
NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields
});
});
it('should NOT remove hidden columns even when replaceall is set', async function () {
await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, {
manualSort: {type: "ManualSortPos"}, NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields
}, { getParams: { hidden: true } });
});
it('should forbid update by viewers', async function () {
// given
const { url, docId } = await generateDocAndUrl();
await userApi.updateDocPermissions(docId, {users: {'kiwi@getgrist.com': 'viewers'}});
// when
const resp = await axios.put(url, { columns: [ COLUMN_TO_ADD ] }, kiwi);
// then
assert.equal(resp.status, 403);
});
it("should return 404 when table is not found", async function() {
// given
const { url } = await generateDocAndUrl();
const notFoundUrl = url.replace("Table1", "NonExistingTable");
// when
const resp = await axios.put(notFoundUrl, { columns: [ COLUMN_TO_ADD ] }, chimpy);
// then
assert.equal(resp.status, 404);
assert.equal(resp.data.error, 'Table not found "NonExistingTable"');
});
});
it("GET /docs/{did}/tables/{tid}/data returns 404 for non-existent doc", async function () {
const resp = await axios.get(`${serverUrl}/api/docs/typotypotypo/tables/Table1/data`, chimpy);
assert.equal(resp.status, 404);

Loading…
Cancel
Save