diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index 0e9eb5d15f..1c5690a04f 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -269,7 +269,7 @@ useTooltip(rootEl, (showing) => { return; } - const content = cell.value.violation.violations.map(it => it.result.message).join('\n'); + const content = cell.value.violation.violations.filter(it => !it.valid).map(it => it.result.message).join('\n'); os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), { showing, content, diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index fd6339d244..f1b50d47c3 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -76,12 +76,14 @@ const props = defineProps<{ }>(); // non-reactive +// eslint-disable-next-line vue/no-setup-props-destructure const rowSetting: Required = { ...defaultGridRowSetting, ...props.settings.row, }; // non-reactive +// eslint-disable-next-line vue/no-setup-props-destructure const columnSettings = props.settings.cols; // non-reactive @@ -1117,21 +1119,22 @@ function refreshData() { // 行・列の定義から、元データの配列より値を取得してセルを作成する。 // 行・列の定義はそれぞれインデックスを持っており、そのインデックスは元データの配列番地に対応している。 const _cells: RowHolder[] = _rows.map(row => { - const cells = row.using - ? _cols.map(col => { - const cell = createCell(col, row, _data[row.index][col.setting.bindTo], cellSettings); - cell.violation = cellValidation(cell, cell.value); - return cell; - }) + const newCells = row.using + ? _cols.map(col => createCell(col, row, _data[row.index][col.setting.bindTo], cellSettings)) : _cols.map(col => createCell(col, row, undefined, cellSettings)); - return { row, cells, origin: _data[row.index] }; + return { row, cells: newCells, origin: _data[row.index] }; }); rows.value = _rows; cells.value = _cells; - applyRowRules(_cells.filter(it => it.row.using).flatMap(it => it.cells)); + const allCells = _cells.filter(it => it.row.using).flatMap(it => it.cells); + for (const cell of allCells) { + cell.violation = cellValidation(allCells, cell, cell.value); + } + + applyRowRules(allCells); if (_DEV_) { console.log('[grid][refresh-data][end]'); @@ -1205,7 +1208,6 @@ function patchData(newItems: DataSource[]) { const oldCell = oldCells[colIdx]; const newValue = newItem[_col.setting.bindTo]; if (oldCell.value !== newValue) { - oldCell.violation = cellValidation(oldCell, newValue); oldCell.value = _col.setting.valueTransformer ? _col.setting.valueTransformer(holder.row, _col, newValue) : newValue; @@ -1215,6 +1217,11 @@ function patchData(newItems: DataSource[]) { } if (changedCells.length > 0) { + const allCells = cells.value.slice(0, newItems.length).flatMap(it => it.cells); + for (const cell of allCells) { + cell.violation = cellValidation(allCells, cell, cell.value); + } + applyRowRules(changedCells); // セル値が書き換わっており、バリデーションの結果も変わっているので外部に通知する必要がある diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts index 7b2346b299..bccc923da1 100644 --- a/packages/frontend/src/components/grid/cell-validators.ts +++ b/packages/frontend/src/components/grid/cell-validators.ts @@ -6,6 +6,7 @@ export type ValidatorParams = { column: GridColumn; row: GridRow; value: CellValue; + allCells: GridCell[]; }; export type ValidatorResult = { @@ -13,7 +14,7 @@ export type ValidatorResult = { message?: string; } -export type CellValidator = { +export type GridCellValidator = { name?: string; ignoreViolation?: boolean; validate: (params: ValidatorParams) => ValidatorResult; @@ -27,11 +28,11 @@ export type ValidateViolation = { export type ValidateViolationItem = { valid: boolean; - validator: CellValidator; + validator: GridCellValidator; result: ValidatorResult; } -export function cellValidation(cell: GridCell, newValue: CellValue): ValidateViolation { +export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation { const { column, row } = cell; const validators = column.setting.validators ?? []; @@ -39,6 +40,7 @@ export function cellValidation(cell: GridCell, newValue: CellValue): ValidateVio column, row, value: newValue, + allCells, }; const violations: ValidateViolationItem[] = validators.map(validator => { @@ -58,11 +60,10 @@ export function cellValidation(cell: GridCell, newValue: CellValue): ValidateVio } class ValidatorPreset { - required(): CellValidator { + required(): GridCellValidator { return { name: 'required', - validate: (params: ValidatorParams): ValidatorResult => { - const { value } = params; + validate: ({ value }): ValidatorResult => { return { valid: value !== null && value !== undefined && value !== '', message: 'This field is required.', @@ -71,11 +72,10 @@ class ValidatorPreset { }; } - regex(pattern: RegExp): CellValidator { + regex(pattern: RegExp): GridCellValidator { return { name: 'regex', - validate: (params: ValidatorParams): ValidatorResult => { - const { value, column } = params; + validate: ({ value, column }): ValidatorResult => { if (column.setting.type !== 'text') { return { valid: false, @@ -90,6 +90,22 @@ class ValidatorPreset { }, }; } + + unique(): GridCellValidator { + return { + name: 'unique', + validate: ({ column, row, value, allCells }): ValidatorResult => { + const bindTo = column.setting.bindTo; + const isUnique = allCells + .filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index) + .every(cell => cell.value !== value); + return { + valid: isUnique, + message: 'This value is already used.', + }; + }, + }; + } } export const validators = new ValidatorPreset(); diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts index 5e189224c1..360cf77fe3 100644 --- a/packages/frontend/src/components/grid/column.ts +++ b/packages/frontend/src/components/grid/column.ts @@ -1,4 +1,4 @@ -import { CellValidator } from '@/components/grid/cell-validators.js'; +import { GridCellValidator } from '@/components/grid/cell-validators.js'; import { Size, SizeStyle } from '@/components/grid/grid.js'; import { calcCellWidth } from '@/components/grid/grid-utils.js'; import { CellValue, GridCell } from '@/components/grid/cell.js'; @@ -19,7 +19,7 @@ export type GridColumnSetting = { type: ColumnType; width: SizeStyle; editable?: boolean; - validators?: CellValidator[]; + validators?: GridCellValidator[]; customValueEditor?: CustomValueEditor; valueTransformer?: CellValueTransformer; contextMenuFactory?: GridColumnContextMenuFactory; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts index b5c70295c0..900217c683 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts @@ -17,3 +17,20 @@ export function emptyStrToEmptyArray(value: string) { return value === '' ? [] : value.split(',').map(it => it.trim()); } +export function roleIdsParser(text: string): { id: string, name: string }[] { + // idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない + try { + const obj = JSON.parse(text); + if (!Array.isArray(obj)) { + return []; + } + if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { + return []; + } + + return obj.map(it => ({ id: it.id, name: it.name })); + } catch (ex) { + console.warn(ex); + return []; + } +} diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index e4f43804fa..d3565354d1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -189,13 +189,13 @@ import { emptyStrToEmptyArray, emptyStrToNull, emptyStrToUndefined, - RequestLogItem, + RequestLogItem, roleIdsParser, } from '@/pages/admin/custom-emojis-manager.impl.js'; import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; -import { validators } from '@/components/grid/cell-validators.js'; +import { GridCellValidator, validators } from '@/components/grid/cell-validators.js'; import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; @@ -247,6 +247,7 @@ type GridSortOrder = { function setupGrid(): GridSetting { const required = validators.required(); const regex = validators.regex(/^[a-zA-Z0-9_]+$/); + const unique = validators.unique(); return { row: { showNumber: true, @@ -302,7 +303,10 @@ function setupGrid(): GridSetting { return file.url; }, }, - { bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, validators: [required, regex] }, + { + bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, + validators: [required, regex, unique], + }, { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 }, { bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 }, { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 }, @@ -335,23 +339,7 @@ function setupGrid(): GridSetting { return transform; }, events: { - paste(text) { - // idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない - try { - const obj = JSON.parse(text); - if (!Array.isArray(obj)) { - return []; - } - if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { - return []; - } - - return obj.map(it => ({ id: it.id, name: it.name })); - } catch (ex) { - console.warn(ex); - return []; - } - }, + paste: roleIdsParser, delete(cell) { // デフォルトはundefinedになるが、このプロパティは空配列にしたい gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = []; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue index 2511ccbc66..bea0618a07 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue @@ -75,7 +75,12 @@ import * as Misskey from 'misskey-js'; import { onMounted, ref } from 'vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { emptyStrToEmptyArray, emptyStrToNull, RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; +import { + emptyStrToEmptyArray, + emptyStrToNull, + RequestLogItem, + roleIdsParser, +} from '@/pages/admin/custom-emojis-manager.impl.js'; import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; @@ -117,6 +122,7 @@ type GridItem = { function setupGrid(): GridSetting { const required = validators.required(); const regex = validators.regex(/^[a-zA-Z0-9_]+$/); + const unique = validators.unique(); function removeRows(rows: GridRow[]) { const idxes = [...new Set(rows.map(it => it.index))]; @@ -158,7 +164,10 @@ function setupGrid(): GridSetting { }, cols: [ { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] }, - { bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, validators: [required, regex] }, + { + bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, + validators: [required, regex, unique], + }, { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 }, { bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 }, { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 }, @@ -190,6 +199,13 @@ function setupGrid(): GridSetting { return transform; }, + events: { + paste: roleIdsParser, + delete(cell) { + // デフォルトはundefinedになるが、このプロパティは空配列にしたい + gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = []; + }, + }, }, ], cells: { @@ -343,16 +359,14 @@ async function onDrop(ev: DragEvent) { } async function onFileSelectClicked() { - const driveFiles = await os.promiseDialog( - chooseFileFromPc( - true, - { - uploadFolder: selectedFolderId.value, - keepOriginal: keepOriginalUploading.value, - // 拡張子は消す - nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), - }, - ), + const driveFiles = await chooseFileFromPc( + true, + { + uploadFolder: selectedFolderId.value, + keepOriginal: keepOriginalUploading.value, + // 拡張子は消す + nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), + }, ); gridItems.value.push(...driveFiles.map(fromDriveFile)); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts index c4c5a66241..373f1d3211 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts @@ -118,18 +118,18 @@ function createRender(params: { const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest; const fileId = body.fileId; - const file = storedDriveFiles.find(f => f.id === fileId); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const file = storedDriveFiles.find(f => f.id === fileId)!; const em = emoji({ id: fakeId(), name: body.name, - url: body.url, publicUrl: file.url, originalUrl: file.url, type: file.type, aliases: body.aliases, - category: body.category, - license: body.license, + category: body.category ?? undefined, + license: body.license ?? undefined, localOnly: body.localOnly, isSensitive: body.isSensitive, });