mirror of https://github.com/nocodb/nocodb
93 changed files with 1632 additions and 299 deletions
@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup> |
||||
import { generateUniqueName, onKeyStroke, onMounted, reactive, ref } from '#imports' |
||||
|
||||
const props = defineProps<{ |
||||
title: string |
||||
}>() |
||||
|
||||
const emit = defineEmits<{ |
||||
(event: 'rename', value: string): void |
||||
(event: 'cancel'): void |
||||
}>() |
||||
|
||||
const inputEl = ref() |
||||
|
||||
const visible = ref(true) |
||||
|
||||
const form = reactive({ |
||||
title: props.title, |
||||
}) |
||||
|
||||
function renameFile(fileName: string) { |
||||
visible.value = false |
||||
emit('rename', fileName) |
||||
} |
||||
|
||||
async function useRandomName() { |
||||
form.title = await generateUniqueName() |
||||
} |
||||
|
||||
const rules = { |
||||
title: [{ required: true, message: 'title is required.' }], |
||||
} |
||||
|
||||
function onCancel() { |
||||
visible.value = false |
||||
emit('cancel') |
||||
} |
||||
|
||||
onKeyStroke('Escape', onCancel) |
||||
|
||||
onMounted(() => { |
||||
inputEl.value.select() |
||||
inputEl.value.focus() |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-modal |
||||
:visible="visible" |
||||
:closable="false" |
||||
:mask-closable="false" |
||||
destroy-on-close |
||||
title="Rename file" |
||||
class="nc-attachment-rename-modal" |
||||
width="min(100%, 620px)" |
||||
:footer="null" |
||||
centered |
||||
@cancel="onCancel" |
||||
> |
||||
<div class="flex flex-col items-center justify-center h-full"> |
||||
<a-form class="w-full h-full" no-style :model="form" @finish="renameFile(form.title)"> |
||||
<a-form-item class="w-full" name="title" :rules="rules.title"> |
||||
<a-input ref="inputEl" v-model:value="form.title" class="w-full" :placeholder="$t('general.rename')" /> |
||||
</a-form-item> |
||||
<div class="flex items-center justify-center gap-6 w-full mt-4"> |
||||
<button class="scaling-btn bg-opacity-100" type="submit"> |
||||
<span>{{ $t('general.confirm') }}</span> |
||||
</button> |
||||
<button class="scaling-btn bg-opacity-100" type="button" @click="useRandomName"> |
||||
<span>{{ $t('title.generateRandomName') }}</span> |
||||
</button> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
</a-modal> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.nc-attachment-rename-modal { |
||||
.ant-input-affix-wrapper, |
||||
.ant-input { |
||||
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,132 @@
|
||||
<script setup lang="ts"> |
||||
import type { TreeProps } from 'ant-design-vue' |
||||
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface' |
||||
import { fileMimeTypeList, fileMimeTypes } from './utils' |
||||
import { useGlobal, useVModel } from '#imports' |
||||
|
||||
const props = defineProps<{ |
||||
value: any |
||||
}>() |
||||
|
||||
const emit = defineEmits(['update:value']) |
||||
|
||||
const vModel = useVModel(props, 'value', emit) |
||||
|
||||
const validators = {} |
||||
|
||||
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow() |
||||
|
||||
const { appInfo } = useGlobal() |
||||
|
||||
const searchValue = ref<string>('') |
||||
|
||||
setAdditionalValidations({ |
||||
...validators, |
||||
}) |
||||
|
||||
// set default value |
||||
vModel.value.meta = { |
||||
...(appInfo.value.ee && { |
||||
// Maximum Number of Attachments per cell |
||||
maxNumberOfAttachments: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 50) || 50, |
||||
// Maximum File Size per file |
||||
maxAttachmentSize: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 20) || 20, |
||||
// allow all mime types by default |
||||
supportedAttachmentMimeTypes: ['*'], |
||||
}), |
||||
...vModel.value.meta, |
||||
} |
||||
|
||||
const expandedKeys = ref<(string | number)[]>([]) |
||||
|
||||
const autoExpandParent = ref<boolean>(true) |
||||
|
||||
const allowAllMimeTypeCheckbox = ref(true) |
||||
|
||||
const getParentKey = (key: string | number, tree: TreeProps['treeData']): string | null => { |
||||
if (!tree) return null |
||||
let parentKey |
||||
for (let i = 0; i < tree.length; i++) { |
||||
const node = tree[i] |
||||
if (node.children) { |
||||
if (node.children.some((item) => item.key === key)) { |
||||
parentKey = node.key as string |
||||
} else if (getParentKey(key, node.children)) { |
||||
parentKey = getParentKey(key, node.children) as string |
||||
} |
||||
} |
||||
} |
||||
return parentKey as string |
||||
} |
||||
|
||||
function allowAllMimeTypeCheckboxOnChange(evt: CheckboxChangeEvent) { |
||||
if (evt.target.checked) { |
||||
vModel.value.meta.supportedAttachmentMimeTypes = ['*'] |
||||
} else { |
||||
vModel.value.meta.supportedAttachmentMimeTypes = ['application', 'audio', 'image', 'video', 'misc'] |
||||
} |
||||
} |
||||
|
||||
watch(searchValue, (value) => { |
||||
expandedKeys.value = fileMimeTypeList |
||||
?.map((item: Record<string, any>) => { |
||||
if (item.title.includes(value)) { |
||||
return getParentKey(item.key, fileMimeTypes) |
||||
} |
||||
return null |
||||
}) |
||||
.filter((item: any, i: number, self: any[]) => item && self.indexOf(item) === i) as string[] |
||||
searchValue.value = value |
||||
autoExpandParent.value = true |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-row class="my-2" gutter="8"> |
||||
<a-col :span="12"> |
||||
<a-form-item v-bind="validateInfos['meta.maxNumberOfAttachments']" label="Max Number of Attachments"> |
||||
<a-input-number v-model:value="vModel.meta.maxNumberOfAttachments" :min="1" class="!w-full nc-attachment-max-count" /> |
||||
</a-form-item> |
||||
</a-col> |
||||
|
||||
<a-col :span="12"> |
||||
<a-form-item v-bind="validateInfos['meta.maxAttachmentSize']" label="Max Attachment Size (MB)"> |
||||
<a-input-number v-model:value="vModel.meta.maxAttachmentSize" :min="1" class="!w-full nc-attachment-max-size" /> |
||||
</a-form-item> |
||||
</a-col> |
||||
|
||||
<a-col class="mt-4" :span="24"> |
||||
<a-form-item v-bind="validateInfos['meta.supportedAttachmentMimeTypes']" class="!p-[10px] border-2"> |
||||
<a-checkbox |
||||
v-model:checked="allowAllMimeTypeCheckbox" |
||||
class="nc-allow-all-mime-type-checkbox" |
||||
name="virtual" |
||||
@change="allowAllMimeTypeCheckboxOnChange" |
||||
> |
||||
Allow All Mime Types |
||||
</a-checkbox> |
||||
<div v-if="!allowAllMimeTypeCheckbox" class="mt-[5px]"> |
||||
<a-input-search v-model:value="searchValue" class="mt-[5px] mb-[15px]" placeholder="Search" /> |
||||
<a-tree |
||||
v-model:expanded-keys="expandedKeys" |
||||
v-model:checkedKeys="vModel.meta.supportedAttachmentMimeTypes" |
||||
checkable |
||||
:height="250" |
||||
:tree-data="fileMimeTypes" |
||||
:auto-expand-parent="autoExpandParent" |
||||
class="!bg-gray-50 my-[10px]" |
||||
> |
||||
<template #title="{ title }"> |
||||
<span v-if="title.indexOf(searchValue) > -1"> |
||||
{{ title.substr(0, title.indexOf(searchValue)) }} |
||||
<span class="text-primary font-bold">{{ searchValue }}</span> |
||||
{{ title.substr(title.indexOf(searchValue) + searchValue.length) }} |
||||
</span> |
||||
<span v-else>{{ title }}</span> |
||||
</template> |
||||
</a-tree> |
||||
</div> |
||||
</a-form-item> |
||||
</a-col> |
||||
</a-row> |
||||
</template> |
@ -0,0 +1,178 @@
|
||||
import { Knex } from 'knex'; |
||||
import { NcUpgraderCtx } from './NcUpgrader'; |
||||
import { MetaTable } from '../utils/globals'; |
||||
import Base from '../models/Base'; |
||||
import Model from '../models/Model'; |
||||
import { XKnex } from '../db/sql-data-mapper/index'; |
||||
import NcConnectionMgrv2 from '../utils/common/NcConnectionMgrv2'; |
||||
import { BaseType, UITypes } from 'nocodb-sdk'; |
||||
|
||||
// before 0.103.0, an attachment object was like
|
||||
// [{
|
||||
// "url": "http://localhost:8080/download/noco/xcdb/Sheet-1/title5/39A410.jpeg",
|
||||
// "title": "foo.jpeg",
|
||||
// "mimetype": "image/jpeg",
|
||||
// "size": 6494
|
||||
// }]
|
||||
// in this way, if the base url is changed, the url will be broken
|
||||
// this upgrader is to convert the existing local attachment object to the following format
|
||||
// [{
|
||||
// "url": "http://localhost:8080/download/noco/xcdb/Sheet-1/title5/39A410.jpeg",
|
||||
// "path": "download/noco/xcdb/Sheet-1/title5/39A410.jpeg",
|
||||
// "title": "foo.jpeg",
|
||||
// "mimetype": "image/jpeg",
|
||||
// "size": 6494
|
||||
// }]
|
||||
// the new url will be constructed by `${ncSiteUrl}/${path}` in UI. the old url will be used for fallback
|
||||
// while other non-local attachments will remain unchanged
|
||||
|
||||
function getTnPath(knex: XKnex, tb: Model) { |
||||
const schema = (knex as any).searchPath?.(); |
||||
const clientType = knex.clientType(); |
||||
if (clientType === 'mssql' && schema) { |
||||
return knex.raw('??.??', [schema, tb.table_name]).toQuery(); |
||||
} else if (clientType === 'snowflake') { |
||||
return [ |
||||
knex.client.config.connection.database, |
||||
knex.client.config.connection.schema, |
||||
tb.table_name, |
||||
].join('.'); |
||||
} else { |
||||
return tb.table_name; |
||||
} |
||||
} |
||||
|
||||
export default async function ({ ncMeta }: NcUpgraderCtx) { |
||||
const bases: BaseType[] = await ncMeta.metaList2(null, null, MetaTable.BASES); |
||||
for (const _base of bases) { |
||||
const base = new Base(_base); |
||||
|
||||
// skip if the prodect_id is missing
|
||||
if (!base.project_id) { |
||||
continue; |
||||
} |
||||
|
||||
const project = await ncMeta.metaGet2(null, null, MetaTable.PROJECT, { |
||||
id: base.project_id, |
||||
}); |
||||
|
||||
// skip if the project is missing
|
||||
if (!project) { |
||||
continue; |
||||
} |
||||
|
||||
const isProjectDeleted = project.deleted; |
||||
|
||||
const knex: Knex = base.is_meta |
||||
? ncMeta.knexConnection |
||||
: NcConnectionMgrv2.get(base); |
||||
const models = await base.getModels(ncMeta); |
||||
|
||||
for (const model of models) { |
||||
try { |
||||
// if the table is missing in database, skip
|
||||
if (!(await knex.schema.hasTable(getTnPath(knex, model)))) { |
||||
continue; |
||||
} |
||||
|
||||
const updateRecords = []; |
||||
|
||||
// get all attachment & primary key columns
|
||||
// and filter out the columns that are missing in database
|
||||
const columns = await (await Model.get(model.id, ncMeta)) |
||||
.getColumns(ncMeta) |
||||
.then(async (columns) => { |
||||
const filteredColumns = []; |
||||
|
||||
for (const column of columns) { |
||||
if (column.uidt !== UITypes.Attachment && !column.pk) continue; |
||||
if ( |
||||
!(await knex.schema.hasColumn( |
||||
getTnPath(knex, model), |
||||
column.column_name |
||||
)) |
||||
) |
||||
continue; |
||||
filteredColumns.push(column); |
||||
} |
||||
|
||||
return filteredColumns; |
||||
}); |
||||
|
||||
const attachmentColumns = columns |
||||
.filter((c) => c.uidt === UITypes.Attachment) |
||||
.map((c) => c.column_name); |
||||
if (attachmentColumns.length === 0) { |
||||
continue; |
||||
} |
||||
const primaryKeys = columns |
||||
.filter((c) => c.pk) |
||||
.map((c) => c.column_name); |
||||
|
||||
const records = await knex(getTnPath(knex, model)).select(); |
||||
|
||||
for (const record of records) { |
||||
for (const attachmentColumn of attachmentColumns) { |
||||
let attachmentMeta: Array<{ |
||||
url: string; |
||||
}>; |
||||
|
||||
// if parsing failed ignore the cell
|
||||
try { |
||||
attachmentMeta = |
||||
typeof record[attachmentColumn] === 'string' |
||||
? JSON.parse(record[attachmentColumn]) |
||||
: record[attachmentColumn]; |
||||
} catch {} |
||||
|
||||
// if cell data is not an array, ignore it
|
||||
if (!Array.isArray(attachmentMeta)) { |
||||
continue; |
||||
} |
||||
|
||||
if (attachmentMeta) { |
||||
const newAttachmentMeta = []; |
||||
for (const attachment of attachmentMeta) { |
||||
if ('url' in attachment && typeof attachment.url === 'string') { |
||||
const match = attachment.url.match(/^(.*)\/download\/(.*)$/); |
||||
if (match) { |
||||
// e.g. http://localhost:8080/download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png
|
||||
// match[1] = http://localhost:8080
|
||||
// match[2] = download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png
|
||||
const path = `download/${match[2]}`; |
||||
|
||||
newAttachmentMeta.push({ |
||||
...attachment, |
||||
path, |
||||
}); |
||||
} else { |
||||
// keep it as it is
|
||||
newAttachmentMeta.push(attachment); |
||||
} |
||||
} |
||||
} |
||||
const where = primaryKeys |
||||
.map((key) => { |
||||
return { [key]: record[key] }; |
||||
}) |
||||
.reduce((acc, val) => Object.assign(acc, val), {}); |
||||
updateRecords.push( |
||||
await knex(getTnPath(knex, model)) |
||||
.update({ |
||||
[attachmentColumn]: JSON.stringify(newAttachmentMeta), |
||||
}) |
||||
.where(where) |
||||
); |
||||
} |
||||
} |
||||
} |
||||
await Promise.all(updateRecords); |
||||
} catch (e) { |
||||
// ignore the error related to deleted project
|
||||
if (!isProjectDeleted) { |
||||
throw e; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
After Width: | Height: | Size: 113 KiB |
After Width: | Height: | Size: 931 KiB |
After Width: | Height: | Size: 82 KiB |
After Width: | Height: | Size: 72 KiB |
After Width: | Height: | Size: 88 KiB |
After Width: | Height: | Size: 1.8 MiB |
@ -0,0 +1,140 @@
|
||||
import { ColumnPageObject } from '.'; |
||||
import BasePage from '../../../Base'; |
||||
import { expect } from '@playwright/test'; |
||||
|
||||
export class AttachmentColumnPageObject extends BasePage { |
||||
readonly column: ColumnPageObject; |
||||
|
||||
constructor(column: ColumnPageObject) { |
||||
super(column.rootPage); |
||||
this.column = column; |
||||
} |
||||
|
||||
get() { |
||||
return this.column.get(); |
||||
} |
||||
|
||||
async advanceConfig({ |
||||
columnTitle, |
||||
fileCount, |
||||
fileSize, |
||||
fileTypesExcludeList, |
||||
}: { |
||||
columnTitle: string; |
||||
fileCount?: number; |
||||
fileSize?: number; |
||||
fileTypesExcludeList?: string[]; |
||||
}) { |
||||
await this.column.openEdit({ title: columnTitle }); |
||||
await this.column.editMenuShowMore(); |
||||
|
||||
// text box : nc-attachment-max-count
|
||||
// text box : nc-attachment-max-size
|
||||
// checkbox : ant-tree-checkbox
|
||||
// Checkbox order: Application, Audio, Image, Video, Misc
|
||||
|
||||
if (fileCount) { |
||||
const inputMaxCount = await this.column.get().locator(`.nc-attachment-max-count`); |
||||
await inputMaxCount.locator(`input`).fill(fileCount.toString()); |
||||
} |
||||
|
||||
if (fileSize) { |
||||
const inputMaxSize = await this.column.get().locator(`.nc-attachment-max-size`); |
||||
await inputMaxSize.locator(`input`).fill(fileSize.toString()); |
||||
} |
||||
|
||||
if (fileTypesExcludeList) { |
||||
// click on nc-allow-all-mime-type-checkbox
|
||||
const allowAllMimeCheckbox = await this.column.get().locator(`.nc-allow-all-mime-type-checkbox`); |
||||
await allowAllMimeCheckbox.click(); |
||||
|
||||
const treeList = await this.column.get().locator(`.ant-tree-list`); |
||||
const checkboxList = await treeList.locator(`.ant-tree-treenode`); |
||||
|
||||
for (let i = 0; i < fileTypesExcludeList.length; i++) { |
||||
const fileType = fileTypesExcludeList[i]; |
||||
switch (fileType) { |
||||
case 'Application': |
||||
await checkboxList.nth(0).locator(`.ant-tree-checkbox`).click(); |
||||
break; |
||||
case 'Audio': |
||||
await checkboxList.nth(1).locator(`.ant-tree-checkbox`).click(); |
||||
break; |
||||
case 'Image': |
||||
await checkboxList.nth(2).locator(`.ant-tree-checkbox`).click(); |
||||
break; |
||||
case 'Video': |
||||
await checkboxList.nth(3).locator(`.ant-tree-checkbox`).click(); |
||||
break; |
||||
case 'Misc': |
||||
await checkboxList.nth(4).locator(`.ant-tree-checkbox`).click(); |
||||
break; |
||||
default: |
||||
break; |
||||
} |
||||
} |
||||
|
||||
await this.rootPage.waitForTimeout(1000); |
||||
} |
||||
|
||||
await this.column.save({ isUpdated: true }); |
||||
} |
||||
|
||||
// add multiple options at once after column creation is completed
|
||||
//
|
||||
async addOptions({ columnTitle, options }: { columnTitle: string; options: string[] }) { |
||||
await this.column.openEdit({ title: columnTitle }); |
||||
for (let i = 0; i < options.length; i++) { |
||||
await this.column.get().locator('button:has-text("Add option")').click(); |
||||
await this.column.get().locator(`[data-testid="select-column-option-input-${i}"]`).click(); |
||||
await this.column.get().locator(`[data-testid="select-column-option-input-${i}"]`).fill(options[i]); |
||||
} |
||||
await this.column.save({ isUpdated: true }); |
||||
} |
||||
|
||||
async editOption({ columnTitle, index, newOption }: { index: number; columnTitle: string; newOption: string }) { |
||||
await this.column.openEdit({ title: columnTitle }); |
||||
|
||||
await this.column.get().locator(`[data-testid="select-column-option-input-${index}"]`).click(); |
||||
await this.column.get().locator(`[data-testid="select-column-option-input-${index}"]`).fill(newOption); |
||||
|
||||
await this.column.save({ isUpdated: true }); |
||||
} |
||||
|
||||
async deleteOption({ columnTitle, index }: { index: number; columnTitle: string }) { |
||||
await this.column.openEdit({ title: columnTitle }); |
||||
await this.column.get().locator(`svg[data-testid="select-column-option-remove-${index}"]`).click(); |
||||
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).toHaveClass(/removed/); |
||||
await this.column.save({ isUpdated: true }); |
||||
} |
||||
|
||||
async deleteOptionWithUndo({ columnTitle, index }: { index: number; columnTitle: string }) { |
||||
await this.column.openEdit({ title: columnTitle }); |
||||
await this.column.get().locator(`svg[data-testid="select-column-option-remove-${index}"]`).click(); |
||||
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).toHaveClass(/removed/); |
||||
await this.column.get().locator(`svg[data-testid="select-column-option-remove-undo-${index}"]`).click(); |
||||
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).not.toHaveClass(/removed/); |
||||
await this.column.save({ isUpdated: true }); |
||||
} |
||||
|
||||
async reorderOption({ |
||||
columnTitle, |
||||
sourceOption, |
||||
destinationOption, |
||||
}: { |
||||
columnTitle: string; |
||||
sourceOption: string; |
||||
destinationOption: string; |
||||
}) { |
||||
await this.column.openEdit({ title: columnTitle }); |
||||
await this.column.rootPage.waitForTimeout(150); |
||||
await this.column.rootPage.dragAndDrop( |
||||
`svg[data-testid="select-option-column-handle-icon-${sourceOption}"]`, |
||||
`svg[data-testid="select-option-column-handle-icon-${destinationOption}"]`, |
||||
{ |
||||
force: true, |
||||
} |
||||
); |
||||
await this.column.save({ isUpdated: true }); |
||||
} |
||||
} |
Loading…
Reference in new issue