This commit is contained in:
samunohito 2024-01-28 22:10:24 +09:00
parent aacee3c970
commit e21c43e2aa
13 changed files with 295 additions and 148 deletions

View File

@ -0,0 +1,35 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')">
<div :class="$style.root">
{{ content }}
</div>
</MkTooltip>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkTooltip from '@/components/MkTooltip.vue';
defineProps<{
showing: boolean;
content: string;
targetElement: HTMLElement;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
</script>
<style lang="scss" module>
.root {
font-size: 0.9em;
text-align: left;
text-wrap: normal;
}
</style>

View File

@ -10,9 +10,10 @@
<div <div
:class="[ :class="[
$style.root, $style.root,
[(cell.validation.valid || cell.selected) ? {} : $style.error],
[cell.selected ? $style.selected : {}], [cell.selected ? $style.selected : {}],
[cell.ranged ? $style.ranged : {}], [cell.ranged ? $style.ranged : {}],
[needsContentCentering ? $style.center : {}] [needsContentCentering ? $style.center : {}],
]" ]"
> >
<div v-if="!editing" ref="contentAreaEl"> <div v-if="!editing" ref="contentAreaEl">
@ -47,15 +48,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, toRefs, watch } from 'vue'; import { computed, defineAsyncComponent, nextTick, ref, shallowRef, toRefs, watch } from 'vue';
import { import { GridEventEmitter, Size } from '@/components/grid/grid.js';
CellValue, import { useTooltip } from '@/scripts/use-tooltip.js';
equalCellAddress, import * as os from '@/os.js';
getCellAddress, import { CellValue, GridCell } from '@/components/grid/cell.js';
GridCell, import { equalCellAddress, getCellAddress } from '@/components/grid/utils.js';
GridEventEmitter,
Size,
} from '@/components/grid/types.js';
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'operation:beginEdit', sender: GridCell): void; (ev: 'operation:beginEdit', sender: GridCell): void;
@ -70,9 +68,9 @@ const props = defineProps<{
const { cell, bus } = toRefs(props); const { cell, bus } = toRefs(props);
const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>(); const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>();
const contentAreaEl = ref<InstanceType<typeof HTMLDivElement>>(); const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
const inputAreaEl = ref<InstanceType<typeof HTMLDivElement>>(); const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
const editing = ref<boolean>(false); const editing = ref<boolean>(false);
const editingValue = ref<CellValue>(undefined); const editingValue = ref<CellValue>(undefined);
@ -209,6 +207,19 @@ function emitContentSizeChanged() {
}); });
} }
useTooltip(rootEl, (showing) => {
if (cell.value.validation.valid) {
return;
}
const content = cell.value.validation.violations.map(it => it.result.message).join('\n');
os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), {
showing,
content,
targetElement: rootEl.value,
}, {}, 'closed');
});
</script> </script>
<style module lang="scss"> <style module lang="scss">
@ -250,6 +261,10 @@ $cellHeight: 28px;
&.center { &.center {
justify-content: center; justify-content: center;
} }
&.error {
border: solid 0.5px var(--error);
}
} }
.content { .content {

View File

@ -19,9 +19,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { toRefs } from 'vue'; import { toRefs } from 'vue';
import { CellValue, GridCell, GridEventEmitter, GridRow, Size } from '@/components/grid/types.js'; import { GridEventEmitter, GridRow, Size } from '@/components/grid/grid.js';
import MkDataCell from '@/components/grid/MkDataCell.vue'; import MkDataCell from '@/components/grid/MkDataCell.vue';
import MkNumberCell from '@/components/grid/MkNumberCell.vue'; import MkNumberCell from '@/components/grid/MkNumberCell.vue';
import { CellValue, GridCell } from '@/components/grid/cell.js';
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'operation:beginEdit', sender: GridCell): void; (ev: 'operation:beginEdit', sender: GridCell): void;

View File

@ -36,24 +36,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, toRefs, watch } from 'vue'; import { computed, ref, toRefs, watch } from 'vue';
import { import {
calcCellWidth, CellValueChangedEvent,
CELL_ADDRESS_NONE,
CellAddress,
CellValue, CellValueChangedEvent,
ColumnSetting, ColumnSetting,
DataSource, DataSource,
equalCellAddress,
getCellAddress,
GridCell,
GridColumn, GridColumn,
GridEventEmitter, GridEventEmitter,
GridRow, GridRow,
GridState, GridState,
Size, Size,
} from '@/components/grid/types.js'; } from '@/components/grid/grid.js';
import MkDataRow from '@/components/grid/MkDataRow.vue'; import MkDataRow from '@/components/grid/MkDataRow.vue';
import MkHeaderRow from '@/components/grid/MkHeaderRow.vue'; import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { cellValidation, ValidateViolation } from '@/components/grid/cell-validators.js';
import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
import { calcCellWidth, equalCellAddress, getCellAddress } from '@/components/grid/utils.js';
const props = defineProps<{ const props = defineProps<{
columnSettings: ColumnSetting[], columnSettings: ColumnSetting[],
@ -61,6 +58,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'operation:cellValidation', violation: ValidateViolation): void;
(ev: 'change:cellValue', event: CellValueChangedEvent): void; (ev: 'change:cellValue', event: CellValueChangedEvent): void;
}>(); }>();
@ -526,10 +524,20 @@ function onHeaderCellWidthLargest(sender: GridColumn) {
function setCellValue(sender: GridCell | CellAddress, newValue: CellValue) { function setCellValue(sender: GridCell | CellAddress, newValue: CellValue) {
const cellAddress = 'address' in sender ? sender.address : sender; const cellAddress = 'address' in sender ? sender.address : sender;
cells.value[cellAddress.row][cellAddress.col].value = newValue; const cell = cells.value[cellAddress.row][cellAddress.col];
const violation = cellValidation(cell, newValue);
emit('operation:cellValidation', violation);
cell.validation = {
valid: violation.valid,
violations: violation.violations.filter(it => !it.valid),
};
cell.value = newValue;
emit('change:cellValue', { emit('change:cellValue', {
column: columns.value[cellAddress.col], column: cell.column,
row: rows.value[cellAddress.row], row: cell.row,
value: newValue, value: newValue,
}); });
} }
@ -709,6 +717,10 @@ function refreshData() {
selected: false, selected: false,
ranged: false, ranged: false,
contentSize: { width: 0, height: 0 }, contentSize: { width: 0, height: 0 },
validation: {
valid: true,
violations: [],
},
}; };
rowCells.push(cell); rowCells.push(cell);

View File

@ -22,7 +22,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, toRefs, watch } from 'vue'; import { computed, nextTick, ref, toRefs, watch } from 'vue';
import { GridColumn, GridEventEmitter, Size } from '@/components/grid/types.js'; import { GridColumn, GridEventEmitter, Size } from '@/components/grid/grid.js';
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'operation:beginWidthChange', sender: GridColumn): void; (ev: 'operation:beginWidthChange', sender: GridColumn): void;

View File

@ -19,7 +19,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GridColumn, GridEventEmitter, Size } from '@/components/grid/types.js'; import { GridColumn, GridEventEmitter, Size } from '@/components/grid/grid.js';
import MkHeaderCell from '@/components/grid/MkHeaderCell.vue'; import MkHeaderCell from '@/components/grid/MkHeaderCell.vue';
import MkNumberCell from '@/components/grid/MkNumberCell.vue'; import MkNumberCell from '@/components/grid/MkNumberCell.vue';

View File

@ -5,7 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GridRow } from '@/components/grid/types.js'; import { GridRow } from '@/components/grid/grid.js';
defineProps<{ defineProps<{
content: string, content: string,

View File

@ -0,0 +1,68 @@
import { GridColumn, GridRow } from '@/components/grid/grid.js';
import { CellValue, GridCell } from '@/components/grid/cell.js';
export type ValidatorParams = {
column: GridColumn;
row: GridRow;
value: CellValue;
};
export type ValidatorResult = {
valid: boolean;
message?: string;
}
export type CellValidator = {
name?: string;
validate: (params: ValidatorParams) => ValidatorResult;
}
export type ValidateViolation = {
valid: boolean;
params: ValidatorParams;
violations: ValidateViolationItem[];
}
export type ValidateViolationItem = {
valid: boolean;
validator: CellValidator;
result: ValidatorResult;
}
export function cellValidation(cell: GridCell, newValue: CellValue): ValidateViolation {
const { column, row } = cell;
const validators = column.setting.validators ?? [];
const params: ValidatorParams = {
column,
row,
value: newValue,
};
const violations: ValidateViolationItem[] = validators.map(validator => {
const result = validator.validate(params);
return {
valid: result.valid,
validator,
result,
};
});
return {
valid: violations.every(v => v.result.valid),
params,
violations,
};
}
export const required: CellValidator = {
name: 'required',
validate: (params: ValidatorParams): ValidatorResult => {
const { value } = params;
return {
valid: value !== null && value !== undefined && value !== '',
message: 'This field is required.',
};
},
};

View File

@ -0,0 +1,29 @@
import { ValidateViolationItem } from '@/components/grid/cell-validators.js';
import { GridColumn, GridRow, Size } from '@/components/grid/grid.js';
export type CellValue = string | boolean | number | undefined | null
export type CellAddress = {
row: number;
col: number;
}
export const CELL_ADDRESS_NONE: CellAddress = {
row: -1,
col: -1,
};
export type GridCell = {
address: CellAddress;
value: CellValue;
column: GridColumn;
row: GridRow;
selected: boolean;
ranged: boolean;
contentSize: Size;
validation: {
valid: boolean;
violations: ValidateViolationItem[];
}
}

View File

@ -0,0 +1,45 @@
import { EventEmitter } from 'eventemitter3';
import { CellValidator } from '@/components/grid/cell-validators.js';
import { CellValue } from '@/components/grid/cell.js';
export type DataSource = Record<string, CellValue>;
export type GridState = 'normal' | 'cellSelecting' | 'cellEditing' | 'colResizing' | 'colSelecting' | 'rowSelecting'
export type Size = {
width: number;
height: number;
}
export type SizeStyle = number | 'auto' | undefined;
export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image';
export type ColumnSetting = {
bindTo: string;
title?: string;
type: ColumnType;
width: SizeStyle;
editable?: boolean;
validators?: CellValidator[];
};
export type GridColumn = {
index: number;
setting: ColumnSetting;
width: string;
contentSize: Size;
}
export type GridRow = {
index: number;
}
export type CellValueChangedEvent = {
column: GridColumn;
row: GridRow;
value: CellValue;
}
export class GridEventEmitter extends EventEmitter<{}> {
}

View File

@ -1,116 +0,0 @@
import { EventEmitter } from 'eventemitter3';
export type CellValue = string | boolean | number | undefined | null
export type DataSource = Record<string, CellValue>;
export type GridState = 'normal' | 'cellSelecting' | 'cellEditing' | 'colResizing' | 'colSelecting' | 'rowSelecting'
export type RowState = 'normal' | 'added' | 'deleted'
export type Size = {
width: number;
height: number;
}
export type SizeStyle = number | 'auto' | undefined;
export type CellAddress = {
row: number;
col: number;
}
export const CELL_ADDRESS_NONE: CellAddress = {
row: -1,
col: -1,
};
export type GridCell = {
address: CellAddress;
value: CellValue;
column: GridColumn;
row: GridRow;
selected: boolean;
ranged: boolean;
contentSize: Size;
}
export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image';
export type ColumnSetting = {
bindTo: string;
title?: string;
type: ColumnType;
width: SizeStyle;
editable?: boolean;
};
export type GridColumn = {
index: number;
setting: ColumnSetting;
width: string;
contentSize: Size;
}
export type GridRow = {
index: number;
}
export type CellValueChangedEvent = {
column: GridColumn;
row: GridRow;
value: CellValue;
}
export class GridEventEmitter extends EventEmitter<{}> {
}
export function isElement(elem: any): elem is HTMLElement {
return elem instanceof HTMLElement;
}
export function isCellElement(elem: any): elem is HTMLTableCellElement {
return elem instanceof HTMLTableCellElement;
}
export function isRowElement(elem: any): elem is HTMLTableRowElement {
return elem instanceof HTMLTableRowElement;
}
export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
let node = elem;
for (let i = 0; i < parentNodeCount; i++) {
if (isCellElement(node) && isRowElement(node.parentElement)) {
return {
// ヘッダ行ぶんを除く
row: node.parentElement.rowIndex - 1,
// 数値列ぶんを除く
col: node.cellIndex - 1,
};
}
if (!node.parentElement) {
break;
}
node = node.parentElement;
}
return CELL_ADDRESS_NONE;
}
export function equalCellAddress(a: CellAddress, b: CellAddress): boolean {
return a.row === b.row && a.col === b.col;
}
export function calcCellWidth(widthSetting: SizeStyle): string {
switch (widthSetting) {
case undefined:
case 'auto': {
return 'auto';
}
default: {
return `${widthSetting}px`;
}
}
}

View File

@ -0,0 +1,49 @@
import { SizeStyle } from '@/components/grid/types.js';
import { CELL_ADDRESS_NONE, CellAddress } from '@/components/grid/cell.js';
export function isCellElement(elem: any): elem is HTMLTableCellElement {
return elem instanceof HTMLTableCellElement;
}
export function isRowElement(elem: any): elem is HTMLTableRowElement {
return elem instanceof HTMLTableRowElement;
}
export function calcCellWidth(widthSetting: SizeStyle): string {
switch (widthSetting) {
case undefined:
case 'auto': {
return 'auto';
}
default: {
return `${widthSetting}px`;
}
}
}
export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
let node = elem;
for (let i = 0; i < parentNodeCount; i++) {
if (isCellElement(node) && isRowElement(node.parentElement)) {
return {
// ヘッダ行ぶんを除く
row: node.parentElement.rowIndex - 1,
// 数値列ぶんを除く
col: node.cellIndex - 1,
};
}
if (!node.parentElement) {
break;
}
node = node.parentElement;
}
return CELL_ADDRESS_NONE;
}
export function equalCellAddress(a: CellAddress, b: CellAddress): boolean {
return a.row === b.row && a.col === b.col;
}

View File

@ -35,6 +35,7 @@
<MkGrid <MkGrid
:data="convertedGridItems" :data="convertedGridItems"
:columnSettings="columnSettings" :columnSettings="columnSettings"
@operation:cellValidation="onCellValidation"
@change:cellValue="onChangeCellValue" @change:cellValue="onChangeCellValue"
/> />
</div> </div>
@ -43,7 +44,7 @@
v-if="gridItems.length > 0" v-if="gridItems.length > 0"
:class="$style.buttons" :class="$style.buttons"
> >
<MkButton primary @click="onRegistryClicked">{{ i18n.ts.registration }}</MkButton> <MkButton primary :disabled="registerButtonDisabled" @click="onRegistryClicked">{{ i18n.ts.registration }}</MkButton>
<MkButton @click="onClearClicked">{{ i18n.ts.clear }}</MkButton> <MkButton @click="onClearClicked">{{ i18n.ts.clear }}</MkButton>
</div> </div>
</div> </div>
@ -56,7 +57,7 @@ import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { GridItem, IGridItem } from '@/pages/admin/custom-emojis-grid.impl.js'; import { GridItem, IGridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
import MkGrid from '@/components/grid/MkGrid.vue'; import MkGrid from '@/components/grid/MkGrid.vue';
import { CellValueChangedEvent, ColumnSetting } from '@/components/grid/types.js'; import { CellValueChangedEvent, ColumnSetting } from '@/components/grid/grid.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import { uploadFile } from '@/scripts/upload.js'; import { uploadFile } from '@/scripts/upload.js';
@ -65,6 +66,7 @@ import { defaultStore } from '@/store.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { required, ValidateViolation } from '@/components/grid/cell-validators.js';
type FolderItem = { type FolderItem = {
id?: string; id?: string;
@ -78,8 +80,8 @@ const emit = defineEmits<{
}>(); }>();
const columnSettings: ColumnSetting[] = [ const columnSettings: ColumnSetting[] = [
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 }, { bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50, validators: [required] },
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 }, { bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, validators: [required] },
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 }, { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
{ bindTo: 'aliases', title: 'aliases', 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 }, { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
@ -92,6 +94,8 @@ const uploadFolders = ref<FolderItem[]>([]);
const gridItems = ref<IGridItem[]>([]); const gridItems = ref<IGridItem[]>([]);
const selectedFolderId = ref(defaultStore.state.uploadFolder); const selectedFolderId = ref(defaultStore.state.uploadFolder);
const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading); const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
const registerButtonDisabled = ref<boolean>(false);
const convertedGridItems = computed(() => gridItems.value.map(it => it as Record<string, any>)); const convertedGridItems = computed(() => gridItems.value.map(it => it as Record<string, any>));
async function onRegistryClicked() { async function onRegistryClicked() {
@ -180,6 +184,11 @@ async function onDrop(ev: DragEvent) {
} }
} }
function onCellValidation(violation: ValidateViolation) {
console.log(violation);
registerButtonDisabled.value = !violation.valid;
}
function onChangeCellValue(event: CellValueChangedEvent) { function onChangeCellValue(event: CellValueChangedEvent) {
console.log(event); console.log(event);
const item = gridItems.value[event.row.index]; const item = gridItems.value[event.row.index];