mirror of https://github.com/nocodb/nocodb
Raju Udava
2 years ago
committed by
GitHub
37 changed files with 1238 additions and 84 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,112 @@ |
|||||||
|
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 { 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]); |
||||||
|
} 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: Base[] = await ncMeta.metaList2(null, null, MetaTable.BASES); |
||||||
|
for (const base of bases) { |
||||||
|
const knex: XKnex = base.is_meta |
||||||
|
? ncMeta.knexConnection |
||||||
|
: NcConnectionMgrv2.get(base); |
||||||
|
const models = await (await Base.get(base.id, ncMeta)).getModels(ncMeta); |
||||||
|
for (const model of models) { |
||||||
|
const updateRecords = []; |
||||||
|
const columns = await ( |
||||||
|
await Model.get(model.id, ncMeta) |
||||||
|
).getColumns(ncMeta); |
||||||
|
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([ |
||||||
|
...primaryKeys, |
||||||
|
...attachmentColumns, |
||||||
|
]); |
||||||
|
for (const record of records) { |
||||||
|
for (const attachmentColumn of attachmentColumns) { |
||||||
|
const attachmentMeta = |
||||||
|
typeof record[attachmentColumn] === 'string' |
||||||
|
? JSON.parse(record[attachmentColumn]) |
||||||
|
: record[attachmentColumn]; |
||||||
|
if (attachmentMeta) { |
||||||
|
const newAttachmentMeta = []; |
||||||
|
for (const attachment of attachmentMeta) { |
||||||
|
if ('url' in attachment) { |
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
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