diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index d5446d75..b7454b0f 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -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)) { diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 65f03399..54a972df 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -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'], diff --git a/app/plugin/DocApiTypes-ti.ts b/app/plugin/DocApiTypes-ti.ts index 0f6c26dc..366eb22f 100644 --- a/app/plugin/DocApiTypes-ti.ts +++ b/app/plugin/DocApiTypes-ti.ts @@ -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, diff --git a/app/plugin/DocApiTypes.ts b/app/plugin/DocApiTypes.ts index 8f9fc049..998b635b 100644 --- a/app/plugin/DocApiTypes.ts +++ b/app/plugin/DocApiTypes.ts @@ -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. diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index f49773e5..33cc558c 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -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 { - 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) => { diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 89dc570b..6e21356d 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -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" diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 99924f77..893cbda5 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -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" diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 466ba0ff..4d11ebde 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -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" diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index d6ba906f..aee8d1f1 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -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" diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index c6de2f37..f9ac25b4 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -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": "Нажмите для вставки" diff --git a/test/fixtures/docs/ShiftSelection.grist b/test/fixtures/docs/ShiftSelection.grist new file mode 100644 index 00000000..083021d4 Binary files /dev/null and b/test/fixtures/docs/ShiftSelection.grist differ diff --git a/test/nbrowser/ShiftSelection.ts b/test/nbrowser/ShiftSelection.ts new file mode 100644 index 00000000..48576418 --- /dev/null +++ b/test/nbrowser/ShiftSelection.ts @@ -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 { + 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 { + 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}); + }); + }); diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 768113f2..35348f13 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -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( + result.data.columns.map( + ({id, fields}: {id: string, fields: object}) => [id, fields] + ) + ); + } + + async function checkPut( + columns: [RecordWithStringId, ...RecordWithStringId[]], + params: Record, + expectedFieldsByColId: Record, + 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);