feat(import): change column format during import

Signed-off-by: Luka Trovic <luka@nextcloud.com>
This commit is contained in:
Luka Trovic
2024-03-19 19:46:36 +01:00
committed by Julius Härtl
parent ee361f0217
commit a7f9c804b3
12 changed files with 945 additions and 143 deletions

View File

@ -103,9 +103,13 @@ return [
['name' => 'share#destroy', 'url' => '/share/{id}', 'verb' => 'DELETE'],
// import
['name' => 'import#previewImportTable', 'url' => '/import-preview/table/{tableId}', 'verb' => 'POST'],
['name' => 'import#importInTable', 'url' => '/import/table/{tableId}', 'verb' => 'POST'],
['name' => 'import#previewImportView', 'url' => '/import-preview/view/{viewId}', 'verb' => 'POST'],
['name' => 'import#importInView', 'url' => '/import/view/{viewId}', 'verb' => 'POST'],
['name' => 'import#previewUploadImportTable', 'url' => '/importupload-preview/table/{tableId}', 'verb' => 'POST'],
['name' => 'import#importUploadInTable', 'url' => '/importupload/table/{tableId}', 'verb' => 'POST'],
['name' => 'import#previewUploadImportView', 'url' => '/importupload-preview/view/{viewId}', 'verb' => 'POST'],
['name' => 'import#importUploadInView', 'url' => '/importupload/view/{viewId}', 'verb' => 'POST'],
// search

View File

@ -22,6 +22,10 @@ describe('Import csv', () => {
cy.get('.file-picker__files').contains('test-import').click()
cy.get('.file-picker button span').contains('Choose test-import.csv').click()
cy.get('.modal__content .import-filename', { timeout: 5000 }).should('be.visible')
cy.get('.modal__content button').contains('Preview').click()
cy.get('.file_import__preview tbody tr').should('have.length', 4)
cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*'}).as('importUploadReq')
cy.get('.modal__content button').contains('Import').click()
cy.wait('@importUploadReq')
@ -38,6 +42,10 @@ describe('Import csv', () => {
cy.clickOnTableThreeDotMenu('Import')
cy.get('.modal__content button').contains('Upload from device').click()
cy.get('input[type="file"]').selectFile('cypress/fixtures/test-import.csv', { force: true })
cy.get('.modal__content button').contains('Preview').click()
cy.get('.file_import__preview tbody tr', { timeout: 20000 }).should('have.length', 4)
cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*'}).as('importUploadReq')
cy.get('.modal__content button').contains('Import').click()
cy.wait('@importUploadReq')
@ -104,6 +112,6 @@ if (!['stable27'].includes(Cypress.env('ncVersion'))) {
cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0')
cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0')
})
})
}

View File

@ -45,8 +45,10 @@ describe('Manage a table', () => {
cy.get('.modal__content button').contains('Select from Files').click()
cy.get('.file-picker__files').contains('test-import').click()
cy.get('.file-picker button span').contains('Choose test-import.csv').click()
cy.get('.modal__content button').contains('Preview').click()
cy.get('.file_import__preview tbody tr').should('have.length', 4)
cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*'}).as('importUploadReq')
cy.get('.modal__content button').contains('Import').click()
cy.get('.modal__content button').contains('Import').scrollIntoView().click()
cy.wait('@importUploadReq')
cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4')
cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '0')

View File

@ -48,33 +48,50 @@ class ImportController extends Controller {
$this->l10n = $l10n;
}
/**
* @NoAdminRequired
*/
public function importInTable(int $tableId, String $path, bool $createMissingColumns = true): DataResponse {
return $this->handleError(function () use ($tableId, $path, $createMissingColumns) {
return $this->service->import($tableId, null, $path, $createMissingColumns);
public function previewImportTable(int $tableId, String $path): DataResponse {
return $this->handleError(function () use ($tableId, $path) {
return $this->service->previewImport($tableId, null, $path);
});
}
/**
* @NoAdminRequired
*/
public function importInView(int $viewId, String $path, bool $createMissingColumns = true): DataResponse {
return $this->handleError(function () use ($viewId, $path, $createMissingColumns) {
return $this->service->import(null, $viewId, $path, $createMissingColumns);
public function importInTable(int $tableId, String $path, bool $createMissingColumns = true, array $columnsConfig = []): DataResponse {
return $this->handleError(function () use ($tableId, $path, $createMissingColumns, $columnsConfig) {
return $this->service->import($tableId, null, $path, $createMissingColumns, $columnsConfig);
});
}
/**
* @NoAdminRequired
*/
public function importUploadInTable(int $tableId, bool $createMissingColumns = true): DataResponse {
public function previewImportView(int $viewId, String $path): DataResponse {
return $this->handleError(function () use ($viewId, $path) {
return $this->service->previewImport(null, $viewId, $path);
});
}
/**
* @NoAdminRequired
*/
public function importInView(int $viewId, String $path, bool $createMissingColumns = true, array $columnsConfig = []): DataResponse {
return $this->handleError(function () use ($viewId, $path, $createMissingColumns, $columnsConfig) {
return $this->service->import(null, $viewId, $path, $createMissingColumns, $columnsConfig);
});
}
/**
* @NoAdminRequired
*/
public function previewUploadImportTable(int $tableId): DataResponse {
try {
$file = $this->getUploadedFile('uploadfile');
return $this->handleError(function () use ($tableId, $file, $createMissingColumns) {
return $this->service->import($tableId, null, $file['tmp_name'], $createMissingColumns);
return $this->handleError(function () use ($tableId, $file) {
return $this->service->previewImport($tableId, null, $file['tmp_name']);
});
} catch (UploadException | NotPermittedException $e) {
$this->logger->error('Upload error', ['exception' => $e]);
@ -85,11 +102,43 @@ class ImportController extends Controller {
/**
* @NoAdminRequired
*/
public function importUploadInView(int $viewId, bool $createMissingColumns = true): DataResponse {
public function importUploadInTable(int $tableId, bool $createMissingColumns = true, string $columnsConfig = ''): DataResponse {
try {
$columnsConfigArray = json_decode($columnsConfig, true);
$file = $this->getUploadedFile('uploadfile');
return $this->handleError(function () use ($tableId, $file, $createMissingColumns, $columnsConfigArray) {
return $this->service->import($tableId, null, $file['tmp_name'], $createMissingColumns, $columnsConfigArray);
});
} catch (UploadException | NotPermittedException $e) {
$this->logger->error('Upload error', ['exception' => $e]);
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
/**
* @NoAdminRequired
*/
public function previewUploadImportView(int $viewId): DataResponse {
try {
$file = $this->getUploadedFile('uploadfile');
return $this->handleError(function () use ($viewId, $file, $createMissingColumns) {
return $this->service->import(null, $viewId, $file['tmp_name'], $createMissingColumns);
return $this->handleError(function () use ($viewId, $file) {
return $this->service->previewImport(null, $viewId, $file['tmp_name']);
});
} catch (UploadException | NotPermittedException $e) {
$this->logger->error('Upload error', ['exception' => $e]);
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
/**
* @NoAdminRequired
*/
public function importUploadInView(int $viewId, bool $createMissingColumns = true, string $columnsConfig = ''): DataResponse {
try {
$columnsConfigArray = json_decode($columnsConfig, true);
$file = $this->getUploadedFile('uploadfile');
return $this->handleError(function () use ($viewId, $file, $createMissingColumns, $columnsConfigArray) {
return $this->service->import(null, $viewId, $file['tmp_name'], $createMissingColumns, $columnsConfigArray);
});
} catch (UploadException | NotPermittedException $e) {
$this->logger->error('Upload error', ['exception' => $e]);

View File

@ -227,9 +227,28 @@ class ColumnService extends SuperService {
throw new PermissionError('create column for the table id = '.$table->getId().' is not allowed.');
}
// Add number to title to avoid duplicate
$columns = $this->mapper->findAllByTable($table->getId());
$i = 1;
$newTitle = $title;
while (true) {
$found = false;
foreach ($columns as $column) {
if ($column->getTitle() === $newTitle) {
$found = true;
break;
}
}
if (!$found) {
break;
}
$newTitle = $title . ' (' . $i . ')';
$i++;
}
$time = new DateTime();
$item = new Column();
$item->setTitle($title);
$item->setTitle($newTitle);
$item->setTableId($table->getId());
$item->setType($type);
$item->setSubtype($subtype !== null ? $subtype: '');
@ -572,7 +591,9 @@ class ColumnService extends SuperService {
}
foreach ($columns as $column) {
$this->enhanceColumn($column);
if ($column instanceof Column) {
$this->enhanceColumn($column);
}
}
return $columns;
}

View File

@ -46,6 +46,10 @@ class ImportService extends SuperService {
private int $countErrors = 0;
private int $countParsingErrors = 0;
private array $rawColumnTitles = [];
private array $rawColumnDataTypes = [];
private array $columnsConfig = [];
public function __construct(PermissionsService $permissionsService, LoggerInterface $logger, ?string $userId,
IRootFolder $rootFolder, ColumnService $columnService, RowService $rowService, TableService $tableService, ViewService $viewService, IUserManager $userManager) {
parent::__construct($logger, $userId, $permissionsService);
@ -57,6 +61,126 @@ class ImportService extends SuperService {
$this->userManager = $userManager;
}
public function previewImport(?int $tableId, ?int $viewId, string $path): array {
if ($viewId !== null) {
$this->viewId = $viewId;
} elseif ($tableId) {
$this->tableId = $tableId;
} else {
$e = new \Exception('Neither tableId nor viewId is given.');
$this->logger->error($e->getMessage(), ['exception' => $e]);
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage());
}
$this->createUnknownColumns = false;
$previewData = [];
try {
$userFolder = $this->rootFolder->getUserFolder($this->userId);
$error = false;
if ($userFolder->nodeExists($path)) {
$file = $userFolder->get($path);
$tmpFileName = $file->getStorage()->getLocalFile($file->getInternalPath());
if($tmpFileName) {
$spreadsheet = IOFactory::load($tmpFileName);
$previewData = $this->getPreviewData($spreadsheet->getActiveSheet());
} else {
$error = true;
}
} elseif (\file_exists($path)) {
$spreadsheet = IOFactory::load($path);
$previewData = $this->getPreviewData($spreadsheet->getActiveSheet());
} else {
$error = true;
}
if($error) {
throw new NotFoundError('File for import could not be found.');
}
} catch (NotFoundException|NotPermittedException|NoUserException|InternalError|PermissionError $e) {
$this->logger->warning('Storage for user could not be found', ['exception' => $e]);
throw new NotFoundError('Storage for user could not be found');
}
return $previewData;
}
/**
* @param Worksheet $worksheet
* @throws DoesNotExistException
* @throws InternalError
* @throws MultipleObjectsReturnedException
* @throws NotFoundError
* @throws PermissionError
*/
private function getPreviewData(Worksheet $worksheet): array {
$firstRow = $worksheet->getRowIterator()->current();
$secondRow = $worksheet->getRowIterator()->seek(2)->current();
// Prepare columns data
$columns = [];
$this->getColumns($firstRow, $secondRow);
foreach ($this->rawColumnTitles as $colIndex => $title) {
if ($this->columns[$colIndex] !== '') {
/** @var Column $column */
$column = $this->columns[$colIndex];
$columns[] = $column;
} else {
$columns[] = [
'title' => $title,
'type' => $this->rawColumnDataTypes[$colIndex]['type'],
'subtype' => $this->rawColumnDataTypes[$colIndex]['subtype'],
'numberDecimals' => $this->rawColumnDataTypes[$colIndex]['number_decimals'] ?? 0,
'numberPrefix' => $this->rawColumnDataTypes[$colIndex]['number_prefix'] ?? '',
'numberSuffix' => $this->rawColumnDataTypes[$colIndex]['number_suffix'] ?? '',
];
}
}
// Prepare rows data
$count = 0;
$maxCount = 3;
$rows = [];
foreach ($worksheet->getRowIterator(2) as $row) {
$rowData = [];
$cellIterator = $row->getCellIterator();
$cellIterator->setIterateOnlyExistingCells(false);
foreach ($cellIterator as $cellIndex => $cell) {
$value = $cell->getValue();
$colIndex = (int) $cellIndex;
$column = $this->columns[$colIndex];
if (($column && $column->getType() === 'datetime') || (is_array($columns[$colIndex]) && $columns[$colIndex]['type'] === 'datetime')) {
$value = Date::excelToDateTimeObject($value)->format('Y-m-d H:i');
} elseif (($column && $column->getType() === 'number' && $column->getNumberSuffix() === '%')
|| (is_array($columns[$colIndex]) && $columns[$colIndex]['type'] === 'number' && $columns[$colIndex]['numberSuffix'] === '%')) {
$value = $value * 100;
} elseif (($column && $column->getType() === 'selection' && $column->getSubtype() === 'check')
|| (is_array($columns[$colIndex]) && $columns[$colIndex]['type'] === 'selection' && $columns[$colIndex]['subtype'] === 'check')) {
$value = $cell->getFormattedValue() === 'TRUE' ? 'true' : 'false';
}
$rowData[] = $value;
}
$rows[] = $rowData;
$count++;
if ($count >= $maxCount) {
break;
}
}
return [
'columns' => $columns,
'rows' => $rows,
];
}
/**
* @param int|null $tableId
* @param int|null $viewId
@ -69,7 +193,7 @@ class ImportService extends SuperService {
* @throws NotFoundError
* @throws PermissionError
*/
public function import(?int $tableId, ?int $viewId, string $path, bool $createMissingColumns = true): array {
public function import(?int $tableId, ?int $viewId, string $path, bool $createMissingColumns = true, array $columnsConfig = []): array {
if ($viewId !== null) {
$view = $this->viewService->find($viewId);
if (!$this->permissionsService->canCreateRows($view)) {
@ -101,6 +225,7 @@ class ImportService extends SuperService {
}
$this->createUnknownColumns = $createMissingColumns;
$this->columnsConfig = $columnsConfig;
try {
$userFolder = $this->rootFolder->getUserFolder($this->userId);
@ -274,9 +399,45 @@ class ImportService extends SuperService {
$secondRowCellIterator = $secondRow->getCellIterator();
$titles = [];
$dataTypes = [];
$index = 0;
$countMatchingColumnsFromConfig = 0;
$countCreatedColumnsFromConfig = 0;
foreach ($cellIterator as $cell) {
if ($cell && $cell->getValue() !== null && $cell->getValue() !== '') {
$titles[] = $cell->getValue();
$title = $cell->getValue();
if (isset($this->columnsConfig[$index]) && $this->columnsConfig[$index]['action'] === 'exist' && $this->columnsConfig[$index]['existColumn']) {
$title = $this->columnsConfig[$index]['existColumn']['label'];
$countMatchingColumnsFromConfig++;
}
if (isset($this->columnsConfig[$index]) && $this->columnsConfig[$index]['action'] === 'new' && $this->createUnknownColumns) {
$column = $this->columnService->create(
$this->userId,
$this->tableId,
$this->viewId,
$this->columnsConfig[$index]['type'],
$this->columnsConfig[$index]['subtype'],
$this->columnsConfig[$index]['title'],
$this->columnsConfig[$index]['mandatory'] ?? false,
$this->columnsConfig[$index]['description'] ?? '',
$this->columnsConfig[$index]['textDefault'] ?? '',
$this->columnsConfig[$index]['textAllowedPattern'] ?? '',
$this->columnsConfig[$index]['textMaxLength'] ?? null,
$this->columnsConfig[$index]['numberPrefix'] ?? '',
$this->columnsConfig[$index]['numberSuffix'] ?? '',
$this->columnsConfig[$index]['numberDefault'] ?? null,
$this->columnsConfig[$index]['numberMin'] ?? null,
$this->columnsConfig[$index]['numberMax'] ?? null,
$this->columnsConfig[$index]['numberDecimals'] ?? 0,
$this->columnsConfig[$index]['selectionOptions'] ?? '',
$this->columnsConfig[$index]['selectionDefault'] ?? '',
$this->columnsConfig[$index]['datetimeDefault'] ?? '',
$this->columnsConfig[$index]['selectedViewIds'] ?? []
);
$title = $column->getTitle();
$countCreatedColumnsFromConfig++;
}
$titles[] = $title;
// Convert data type to our data type
$dataTypes[] = $this->parseColumnDataType($secondRowCellIterator->current());
@ -285,9 +446,18 @@ class ImportService extends SuperService {
$this->countErrors++;
}
$secondRowCellIterator->next();
$index++;
}
$this->rawColumnTitles = $titles;
$this->rawColumnDataTypes = $dataTypes;
try {
$this->columns = $this->columnService->findOrCreateColumnsByTitleForTableAsArray($this->tableId, $this->viewId, $titles, $dataTypes, $this->userId, $this->createUnknownColumns, $this->countCreatedColumns, $this->countMatchingColumns);
if (!empty($this->columnsConfig)) {
$this->countMatchingColumns = $countMatchingColumnsFromConfig;
$this->countCreatedColumns = $countCreatedColumnsFromConfig;
}
} catch (Exception $e) {
throw new InternalError($e->getMessage());
}

View File

@ -144,107 +144,111 @@ export default {
</style>
<style lang="scss">
h1 {
font-size: 1.98em;
}
h1 {
font-size: 1.98em;
}
h2 {
font-size: larger;
}
h2 {
font-size: larger;
}
h3 {
font-size: large;
}
h3 {
font-size: large;
}
h4 {
font-size: medium;
font-weight: 300;
}
h4 {
font-size: medium;
font-weight: 300;
}
p, .p {
padding-top: 5px;
padding-bottom: 7px;
}
p, .p {
padding-top: 5px;
padding-bottom: 7px;
}
.editor-wrapper p, .editor-wrapper .p {
padding: 0;
}
.editor-wrapper p, .editor-wrapper .p {
padding: 0;
}
p span, .p span, p .span, .p .span, .p.span, p.span, .light {
color: var(--color-text-maxcontrast);
}
p span, .p span, p .span, .p .span, .p.span, p.span, .light {
color: var(--color-text-maxcontrast);
}
p code {
white-space: pre-wrap;
background-color: var(--color-background-dark);
border-radius: var(--border-radius);
padding: 0.8em 1em;
margin-bottom: 0.8em;
font-family: 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', monospace;
}
p code {
white-space: pre-wrap;
background-color: var(--color-background-dark);
border-radius: var(--border-radius);
padding: 0.8em 1em;
margin-bottom: 0.8em;
font-family: 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', monospace;
}
.bold {
font-weight: bold;
}
.bold {
font-weight: bold;
}
.light {
font-weight: 100;
color: var(--color-text-lighter);
/*
.light {
font-weight: 100;
color: var(--color-text-lighter);
/*
background-color: var(--color-background-hover);
*/
}
}
button[class^='icon-'] {
min-width: 36px !important;
}
button[class^='icon-'] {
min-width: 36px !important;
}
[class^='col-'] > span {
color: var(--color-text-maxcontrast);
}
[class^='col-'] > span {
color: var(--color-text-maxcontrast);
}
.icon-left {
background-position: left;
padding-left: 22px;
}
.icon-left {
background-position: left;
padding-left: 22px;
}
.mandatory {
font-weight: bold;
}
.mandatory {
font-weight: bold;
}
.v-popover button {
height: 25px;
margin-left: 10px;
background-color: transparent;
border: none;
}
.v-popover button {
height: 25px;
margin-left: 10px;
background-color: transparent;
border: none;
}
.popover__inner p, .v-popper__inner table {
padding: 15px;
}
.popover__inner p, .v-popper__inner table {
padding: 15px;
}
.v-popper__inner table td {
padding-right: 15px;
}
.v-popper__inner table td {
padding-right: 15px;
}
.error {
color: var(--color-error);
}
.error {
color: var(--color-error);
}
.error input {
border-color: var(--color-error);
}
.error input {
border-color: var(--color-error);
}
.icon-loading:first-child {
top: 10vh;
}
.icon-loading:first-child {
top: 10vh;
}
.block {
display: block !important;
}
.block {
display: block !important;
}
.align-right {
text-align: right;
}
.align-right {
text-align: right;
}
.inline-flex {
display: inline-flex;
}
</style>

View File

@ -132,6 +132,9 @@ export default {
this.combinedType = this.columnId
},
},
mounted() {
this.combinedType = this.columnId
},
}
</script>
<style lang="scss" scoped>

View File

@ -1,6 +1,6 @@
<template>
<NcModal v-if="showModal" size="large" @close="actionCancel">
<div class="modal__content">
<div class="modal__content create-column">
<div class="row">
<div class="col-4">
<h2>{{ t('tables', 'Create column') }}</h2>
@ -142,6 +142,14 @@ export default {
type: Object,
default: null,
},
isCustomSave: {
type: Boolean,
default: false,
},
preset: {
type: Object,
default: null,
},
},
data() {
return {
@ -211,6 +219,33 @@ export default {
watch: {
combinedType() {
this.reset(false, false)
if (this.preset) {
for (const key in this.preset) {
if (['type', 'subtype'].includes(key)) {
continue
}
if (Object.hasOwn(this.column, key)) {
this.column[key] = this.preset[key]
}
}
}
},
preset: {
handler() {
if (this.preset) {
const combinedType = this.preset.type + (this.preset.subtype ? `-${this.preset.subtype}` : '')
if (combinedType !== this.combinedType) {
this.combinedType = combinedType
} else {
for (const key in this.preset) {
if (Object.hasOwn(this.column, key)) {
this.column[key] = this.preset[key]
}
}
}
}
},
deep: true,
},
showModal() {
this.$nextTick(() => {
@ -237,6 +272,12 @@ export default {
showInfo(t('tables', 'You need to select a type for the new column.'))
this.typeMissingError = true
} else {
this.$emit('save', this.prepareSubmitData())
if (this.isCustomSave) {
this.reset()
this.$emit('close')
return
}
await this.sendNewColumnToBE()
if (this.addNewAfterSave) {
this.reset(true, true, false)
@ -250,42 +291,46 @@ export default {
this.reset()
this.$emit('close')
},
prepareSubmitData() {
const data = {
type: this.column.type,
subtype: this.column.subtype,
title: this.column.title,
description: this.column.description,
selectedViewIds: this.column.selectedViews.map(view => view.id),
mandatory: this.column.mandatory,
viewId: this.isView ? this.element.id : null,
tableId: !this.isView ? this.element.id : null,
}
if (this.combinedType === ColumnTypes.TextLine || this.combinedType === ColumnTypes.TextLong) {
data.textDefault = this.column.textDefault
data.textMaxLength = this.column.textMaxLength
} else if (this.combinedType === ColumnTypes.TextRich) {
data.textDefault = this.column.textDefault
} else if (this.combinedType === ColumnTypes.TextLink) {
data.textAllowedPattern = this.column.textAllowedPattern
} else if (this.column.type === 'selection') {
data.selectionDefault = typeof this.column.selectionDefault !== 'string' ? JSON.stringify(this.column.selectionDefault) : this.column.selectionDefault
if (this.column.subtype !== 'check') {
data.selectionOptions = JSON.stringify(this.column.selectionOptions)
}
} else if (this.column.type === 'datetime') {
data.datetimeDefault = this.column.datetimeDefault ? this.column.subtype === 'date' ? 'today' : 'now' : ''
} else if (this.column.type === 'number') {
data.numberDefault = this.column.numberDefault
if (this.column.subtype === '') {
data.numberDecimals = this.column.numberDecimals
data.numberMin = this.column.numberMin
data.numberMax = this.column.numberMax
data.numberPrefix = this.column.numberPrefix
data.numberSuffix = this.column.numberSuffix
}
}
return data
},
async sendNewColumnToBE() {
try {
const data = {
type: this.column.type,
subtype: this.column.subtype,
title: this.column.title,
description: this.column.description,
selectedViewIds: this.column.selectedViews.map(view => view.id),
mandatory: this.column.mandatory,
viewId: this.isView ? this.element.id : null,
tableId: !this.isView ? this.element.id : null,
}
if (this.combinedType === ColumnTypes.TextLine || this.combinedType === ColumnTypes.TextLong) {
data.textDefault = this.column.textDefault
data.textMaxLength = this.column.textMaxLength
} else if (this.combinedType === ColumnTypes.TextRich) {
data.textDefault = this.column.textDefault
} else if (this.combinedType === ColumnTypes.TextLink) {
data.textAllowedPattern = this.column.textAllowedPattern
} else if (this.column.type === 'selection') {
data.selectionDefault = typeof this.column.selectionDefault !== 'string' ? JSON.stringify(this.column.selectionDefault) : this.column.selectionDefault
if (this.column.subtype !== 'check') {
data.selectionOptions = JSON.stringify(this.column.selectionOptions)
}
} else if (this.column.type === 'datetime') {
data.datetimeDefault = this.column.datetimeDefault ? this.column.subtype === 'date' ? 'today' : 'now' : ''
} else if (this.column.type === 'number') {
data.numberDefault = this.column.numberDefault
if (this.column.subtype === '') {
data.numberDecimals = this.column.numberDecimals
data.numberMin = this.column.numberMin
data.numberMax = this.column.numberMax
data.numberPrefix = this.column.numberPrefix
data.numberSuffix = this.column.numberSuffix
}
}
const data = this.prepareSubmitData()
const res = await this.$store.dispatch('insertNewColumn', { isView: this.isView, elementId: this.element.id, data })
if (res) {
showSuccess(t('tables', 'The column "{column}" was created.', { column: this.column.title }))

View File

@ -3,12 +3,12 @@
<div class="modal__content">
<div class="row">
<div class="col-4">
<h2>{{ t('tables', 'Import table') }}</h2>
<h2>{{ title }}</h2>
</div>
</div>
<!-- Starting -->
<div v-if="!loading && result === null && !waitForReload">
<div v-if="!loading && result === null && preview === null && !waitForReload">
<div class="row space-T">
{{ t('tables', 'Add data to the table from a file') }}
</div>
@ -58,7 +58,20 @@
<div class="row">
<div class="fix-col-4 end">
<NcButton :aria-label="t('tables', 'Import')" type="primary" @click="actionSubmit">
<NcButton :aria-label="t('tables', 'Preview')" type="primary" @click="actionPreview">
{{ t('tables', 'Preview') }}
</NcButton>
</div>
</div>
</div>
<!-- show preview -->
<div v-if="!loading && preview !== null && !result && !waitForReload">
<ImportPreview :preview-data="preview" :element="element" :create-missing-columns="createMissingColumns" @update:columns="onUpdateColumnsConfig" />
<div class="row">
<div class="fix-col-4 space-T end">
<NcButton :aria-label="t('tables', 'Import')" type="primary" @click="actionImport">
{{ t('tables', 'Import') }}
</NcButton>
</div>
@ -107,6 +120,8 @@ import { generateUrl } from '@nextcloud/router'
import { mapGetters } from 'vuex'
import NcIconTimerSand from '../../shared/components/ncIconTimerSand/NcIconTimerSand.vue'
import ImportResults from './ImportResults.vue'
import ImportPreview from './ImportPreview.vue'
import { translate as t } from '@nextcloud/l10n'
export default {
@ -119,6 +134,7 @@ export default {
NcModal,
NcButton,
ImportResults,
ImportPreview,
NcCheckboxRadioSwitch,
RowFormWrapper,
NcEmptyContent,
@ -148,6 +164,8 @@ export default {
pathError: false,
loading: false,
result: null,
preview: null,
columnsConfig: [],
waitForReload: false,
mimeTypes: [
'text/csv',
@ -179,6 +197,15 @@ export default {
return fileName
},
title() {
let title = t('tables', 'Import table')
if (!this.loading && this.preview !== null && !this.result && !this.waitForReload) {
title = t('tables', 'Preview imported table')
}
return title
},
},
watch: {
element() {
@ -223,14 +250,14 @@ export default {
this.actionCancel()
},
actionSubmit() {
actionPreview() {
if (this.selectedUploadFile && this.selectedUploadFile.type !== '' && !this.mimeTypes.includes(this.selectedUploadFile.type)) {
showWarning(t('tables', 'The selected file is not supported.'))
return null
}
if (this.selectedUploadFile) {
this.uploadFile()
this.previewImportFromUploadFile()
return
}
@ -240,12 +267,119 @@ export default {
return null
}
this.pathError = false
this.import()
this.previewImportFromPath()
},
async import() {
async previewImportFromPath() {
this.loading = true
try {
const res = await axios.post(generateUrl('/apps/tables/import/' + (this.isElementView ? 'view' : 'table') + '/' + this.element.id), { path: this.path, createMissingColumns: this.getCreateMissingColumns })
const res = await axios.post(generateUrl('/apps/tables/import-preview/' + (this.isElementView ? 'view' : 'table') + '/' + this.element.id), { path: this.path })
if (res.status === 200) {
this.preview = res.data
this.loading = false
} else if (res.status === 401) {
console.debug('error while importing', res)
showError(t('tables', 'Could not import, not authorized. Are you logged in?'))
} else if (res.status === 403) {
console.debug('error while importing', res)
showError(t('tables', 'Could not import, missing needed permission.'))
} else if (res.status === 404) {
console.debug('error while importing', res)
showError(t('tables', 'Could not import, needed resources were not found.'))
} else {
showError(t('tables', 'Could not import data due to unknown errors.'))
console.debug('error while importing', res)
}
} catch (e) {
console.error(e)
return false
}
},
async previewImportFromUploadFile() {
this.loading = true
try {
const url = generateUrl('/apps/tables/importupload-preview/' + (this.isElementView ? 'view' : 'table') + '/' + this.element.id)
const formData = new FormData()
formData.append('uploadfile', this.selectedUploadFile)
const res = await axios.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
if (res.status === 200) {
this.preview = res.data
this.loading = false
} else if (res.status === 401) {
console.debug('error while importing', res)
showError(t('tables', 'Could not import, not authorized. Are you logged in?'))
} else if (res.status === 403) {
console.debug('error while importing', res)
showError(t('tables', 'Could not import, missing needed permission.'))
} else if (res.status === 404) {
console.debug('error while importing', res)
showError(t('tables', 'Could not import, needed resources were not found.'))
} else {
showError(t('tables', 'Could not import data due to unknown errors.'))
console.debug('error while importing', res)
}
} catch (e) {
console.error(e)
return false
}
},
actionImport() {
if (this.selectedUploadFile && this.selectedUploadFile.type !== '' && !this.mimeTypes.includes(this.selectedUploadFile.type)) {
showWarning(t('tables', 'The selected file is not supported.'))
return null
}
if (!this.validateColumnsConfig()) {
return null
}
if (this.selectedUploadFile) {
this.importFromUploadFile()
return null
}
if (this.path === '') {
showWarning(t('tables', 'Please select a file.'))
this.pathError = true
return null
}
this.pathError = false
this.importFromPath()
},
validateColumnsConfig() {
const existColumnCount = {}
for (const column of this.columnsConfig) {
if (column.action === 'exist') {
if (!column.existColumn) {
showWarning(t('tables', 'Please select column for mapping.'))
return false
}
if (existColumnCount[column.existColumn.id] === undefined) {
existColumnCount[column.existColumn.id] = 1
} else {
existColumnCount[column.existColumn.id]++
}
}
}
if (Object.values(existColumnCount).some(count => count > 1)) {
showWarning(t('tables', 'Cannot map same exist column for multiple columns.'))
return false
}
return true
},
async importFromPath() {
this.loading = true
try {
const res = await axios.post(
generateUrl('/apps/tables/import/' + (this.isElementView ? 'view' : 'table') + '/' + this.element.id),
{ path: this.path, createMissingColumns: this.getCreateMissingColumns, columnsConfig: this.columnsConfig },
)
if (res.status === 200) {
this.result = res.data
} else {
@ -269,13 +403,14 @@ export default {
}
this.loading = false
},
async uploadFile() {
async importFromUploadFile() {
this.loading = true
try {
const url = generateUrl('/apps/tables/importupload/' + (this.isElementView ? 'view' : 'table') + '/' + this.element.id)
const formData = new FormData()
formData.append('uploadfile', this.selectedUploadFile)
formData.append('createMissingColumns', this.getCreateMissingColumns)
formData.append('columnsConfig', JSON.stringify(this.columnsConfig))
const res = await axios.post(url, formData, {
headers: {
@ -315,6 +450,7 @@ export default {
this.pathError = false
this.createMissingColumns = true
this.result = null
this.preview = null
this.loading = false
},
pickFile() {
@ -345,6 +481,9 @@ export default {
onUploadFileInputChange(event) {
this.selectedUploadFile = event.target.files[0]
},
onUpdateColumnsConfig(event) {
this.columnsConfig = event
},
},
}

View File

@ -0,0 +1,349 @@
<template>
<div>
<table class="file_import__preview">
<tbody>
<tr v-for="(column, colIndex) in columnsConfig" :key="colIndex" class="row">
<td class="left space-T">
<div class="space-L space-B">
<div class="col-4 action-selections">
<div class="w-100 w-mobile-85">
<div class="column-title">
<span>{{ column.title }} ·</span> {{ columnsConfig[colIndex].typeLabel }}
</div>
<div class="column-values">
<span>{{ exampleValues(colIndex) }}</span>
</div>
</div>
<div class="content-center">
<NcButton v-if="createMissingColumns" :disabled="column.action !== 'new'" @click="editColumn(colIndex)">
<template #icon>
<PencilIcon />
</template>
</NcButton>
</div>
</div>
<div class="no-padding-on-mobile">
<div class="flex w-100">
<NcCheckboxRadioSwitch v-if="createMissingColumns" :checked.sync="columnsConfig[colIndex].action" value="new" name="columnActionSelection" type="radio" @update:checked="$forceUpdate()">
{{ t('tables', 'Create new column') }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-4 action-selections mobile-block">
<div class="w-100">
<NcCheckboxRadioSwitch :checked.sync="columnsConfig[colIndex].action" value="exist" name="columnActionSelection" type="radio" @update:checked="$forceUpdate()">
{{ t('tables', 'Import to existing column') }}
</NcCheckboxRadioSwitch>
</div>
<div class="inline-flex">
<NcSelect v-model="columnsConfig[colIndex].existColumn"
:disabled="columnsConfig[colIndex].action !== 'exist'"
:options="existingColumnOptions"
:selectable="selectableExistingColumnOption"
:aria-label-combobox="t('tables', 'Existing column')" />
</div>
</div>
<div v-if="!createMissingColumns" class="flex w-100">
<NcCheckboxRadioSwitch :checked.sync="columnsConfig[colIndex].action" value="ignore" name="columnActionSelection" type="radio" @update:checked="$forceUpdate()">
{{ t('tables', 'Ignore column') }}
</NcCheckboxRadioSwitch>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import { translate as t } from '@nextcloud/l10n'
import { NcButton, NcCheckboxRadioSwitch, NcSelect } from '@nextcloud/vue'
import { mapGetters } from 'vuex'
import PencilIcon from 'vue-material-design-icons/Pencil.vue'
import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandler.js'
import { emit } from '@nextcloud/event-bus'
export default {
name: 'ImportPreview',
components: {
NcCheckboxRadioSwitch,
NcButton,
NcSelect,
PencilIcon,
},
props: {
previewData: {
type: Object,
default() {
return {
columns: [],
rows: [],
}
},
},
element: {
type: Object,
default: null,
},
createMissingColumns: {
type: Boolean,
default: false,
},
},
data() {
return {
editingColumnIndex: null,
editingColumnPreset: null,
existingColumns: [],
columnsConfig: [],
}
},
computed: {
...mapGetters(['isView']),
existingColumnOptions() {
if (!this.existingColumns.length) {
return []
}
return this.existingColumns.map(column => ({
id: column.id,
label: column.title,
}))
},
},
watch: {
columnsConfig: {
handler(newVal) {
this.$emit('update:columns', newVal)
},
deep: true,
},
},
async mounted() {
this.existingColumns = await this.$store.dispatch('getColumnsFromBE', {
tableId: !this.isView ? this.element.id : null,
viewId: this.isView ? this.element.id : null,
})
for (let i = 0; i < this.previewData.columns.length; i++) {
const typeName = this.previewData.columns[i].type + (this.previewData.columns[i].subtype ? `-${this.previewData.columns[i].subtype}` : '')
this.columnsConfig.push({
titleRaw: this.previewData.columns[i].title,
title: this.previewData.columns[i].title,
description: t('tables', 'This column was automatically created by the import service.'),
action: this.previewData.columns[i].id ? 'exist' : this.createMissingColumns ? 'new' : 'ignore',
existColumn: this.previewData.columns[i].id ? this.existingColumnOptions.find(col => col.id === this.previewData.columns[i].id) : null,
type: this.previewData.columns[i].type,
subtype: this.previewData.columns[i].subtype,
typeName,
typeLabel: this.getTypeLabel(typeName),
numberDecimals: this.previewData.columns[i].numberDecimals,
numberPrefix: this.previewData.columns[i].numberPrefix,
})
}
},
methods: {
t,
editColumn(index) {
this.editingColumnPreset = this.columnsConfig[index]
this.editingColumnIndex = index
emit('tables:column:create', { isView: this.isView, element: this.element, onSave: this.onSaveColumn, preset: this.editingColumnPreset })
// Fix modal overlay
setTimeout(() => {
const modal = document.querySelector('.modal__content.create-column').closest('.modal-mask')
document.body.appendChild(modal)
}, 500)
},
onSaveColumn(column) {
this.columnsConfig[this.editingColumnIndex] = column
this.columnsConfig[this.editingColumnIndex].titleRaw = this.previewData.columns[this.editingColumnIndex].title
this.columnsConfig[this.editingColumnIndex].action = 'new'
this.columnsConfig[this.editingColumnIndex].typeName = column.type + (column.subtype ? `-${column.subtype}` : '')
this.columnsConfig[this.editingColumnIndex].typeLabel = this.getTypeLabel(this.columnsConfig[this.editingColumnIndex].typeName)
this.editingColumnPreset = null
this.editingColumnIndex = null
this.$emit('update:columns', this.columnsConfig)
this.$forceUpdate()
},
getTypeLabel(typeName) {
switch (typeName) {
case ColumnTypes.TextLine:
case ColumnTypes.TextLong:
case ColumnTypes.TextRich:
return t('tables', 'Text')
case ColumnTypes.TextLink:
return t('tables', 'Link')
case ColumnTypes.Number:
return t('tables', 'Number')
case ColumnTypes.NumberStars:
return t('tables', 'Stars rating')
case ColumnTypes.NumberProgress:
return t('tables', 'Progress bar')
case ColumnTypes.Selection:
case ColumnTypes.SelectionMulti:
case ColumnTypes.SelectionCheck:
return t('tables', 'Selection')
case ColumnTypes.Datetime:
case ColumnTypes.DatetimeDate:
case ColumnTypes.DatetimeTime:
return t('tables', 'Date and time')
default:
return ''
}
},
selectableExistingColumnOption(option) {
return !this.columnsConfig.some(column => column.existColumn?.id === option.id)
},
exampleValues(colIndex) {
let result = ''
for (let i = 0; i < this.previewData.rows.length; i++) {
// Limit value length to 30 characters
let value = this.previewData.rows[i][colIndex]
if (value.length > 20) {
value = value.substring(0, 20) + '...'
}
if (i > 0) {
result += ', '
}
result += value
}
if (result.length > 0) {
result += '...'
}
return result
},
},
}
</script>
<style lang="scss" scoped>
.file_import__preview {
margin: auto;
& caption {
font-weight: bold;
margin: calc(var(--default-grid-baseline) * 2) auto;
}
}
.w-100 {
width: 100%;
}
.flex {
display: flex;
}
table {
position: relative;
border-collapse: collapse;
border-spacing: 0;
table-layout: auto;
width: 100%;
border: none;
td, th {
padding-right: 8px;
max-width: 200px;
overflow: hidden;
white-space: nowrap;
ul {
li {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
td:not(:first-child), th:not(:first-child) {
padding-right: 8px;
padding-left: 8px;
}
tr {
height: 51px;
background-color: var(--color-main-background);
}
thead {
tr {
th {
vertical-align: middle;
color: var(--color-text-maxcontrast);
}
}
}
tbody {
td {
text-align: left;
vertical-align: middle;
&.left {
max-width: 300px;
}
&.right {
text-align: right;
}
}
tr:hover {
background-color: var(--color-background-dark);
}
}
}
.action-selections {
display: inline-flex;
}
.column-title {
width: 80%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: normal;
span {
font-weight: bold;
}
}
.column-values {
width: 80%;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-maxcontrast);
}
@media only screen and (max-width: 641px) {
.w-mobile-85 {
width: 85%;
}
.mobile-block {
display: block;
}
}
:deep(.v-select.select) {
max-width: 220px;
}
.content-center {
align-content: center;
}
</style>

View File

@ -5,7 +5,7 @@
<EditTable :table-id="editTable" :show-modal="editTable !== null" @close="editTable = null" />
<TransferTable :table="tableToTransfer" :show-modal="tableToTransfer !== null" @close="tableToTransfer = null" />
<CreateColumn :show-modal="createColumnInfo !== null" :is-view="createColumnInfo?.isView" :element="createColumnInfo?.element" @close="createColumnInfo = null" />
<CreateColumn :show-modal="createColumnInfo !== null" :is-view="createColumnInfo?.isView" :element="createColumnInfo?.element" :preset="createColumnInfo?.preset" :is-custom-save="!!createColumnInfo?.onSave" @save="onSaveNewColumn" @close="createColumnInfo = null" />
<EditColumn v-if="columnToEdit" :column="columnToEdit?.column" :is-view="columnToEdit.isView" :element-id="columnToEdit?.elementId" @close="columnToEdit = false" />
<DeleteColumn v-if="columnToDelete" :is-view="columnToDelete?.isView" :element-id="columnToDelete?.elementId" :column-to-delete="columnToDelete?.column" @cancel="columnToDelete = null" />
@ -178,5 +178,13 @@ export default {
unsubscribe('tables:context:transfer', context => { this.contextToTransfer = context })
unsubscribe('tables:context:delete', context => { this.contextToDelete = context })
},
methods: {
onSaveNewColumn(event) {
if (this.createColumnInfo?.onSave) {
this.createColumnInfo.onSave(event)
}
},
},
}
</script>