[Maps] make EMS tooltips configurable (#34325)
- users can now select which fields to use in EMS tooltips - extracts tooltip-selection to its own component - use human readable names for field names of EMS fields instead of the id when they are displayed in combo-box and tooltip
This commit is contained in:
parent
36378ed3a3
commit
b57f808cdb
19
x-pack/legacy/plugins/maps/public/shared/components/__snapshots__/tooltip_selector.test.js.snap
generated
Normal file
19
x-pack/legacy/plugins/maps/public/shared/components/__snapshots__/tooltip_selector.test.js.snap
generated
Normal file
|
@ -0,0 +1,19 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TooltipSelector should create eui row component 1`] = `
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
fullWidth={false}
|
||||
hasEmptyLabelSpace={false}
|
||||
label="Fields to display in tooltip"
|
||||
labelType="label"
|
||||
>
|
||||
<MultiFieldSelect
|
||||
fields={Array []}
|
||||
filterField={[Function]}
|
||||
onChange={[Function]}
|
||||
placeholder="Select fields"
|
||||
value={Array []}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
`;
|
|
@ -23,6 +23,7 @@ export function MultiFieldSelect({
|
|||
placeholder,
|
||||
...rest
|
||||
}) {
|
||||
|
||||
if (!fields) {
|
||||
return null;
|
||||
}
|
||||
|
@ -34,11 +35,18 @@ export function MultiFieldSelect({
|
|||
onChange(fieldNamesArray);
|
||||
};
|
||||
|
||||
const selectedOptions = value
|
||||
? value.map(fieldName => {
|
||||
return { value: fieldName, label: fieldName };
|
||||
})
|
||||
: [];
|
||||
let selectedOptions;
|
||||
if (value) {
|
||||
selectedOptions = value.map(fieldName => {
|
||||
const matchingField = fields.find(field => {
|
||||
return field.name === fieldName;
|
||||
});
|
||||
const labelValue = matchingField && matchingField.label ? matchingField.label : fieldName;
|
||||
return { value: fieldName, label: labelValue };
|
||||
});
|
||||
} else {
|
||||
selectedOptions = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
|
|
|
@ -12,41 +12,51 @@ import {
|
|||
EuiComboBox,
|
||||
} from '@elastic/eui';
|
||||
|
||||
|
||||
const sortByLabel = (a, b) => {
|
||||
if (a.label < b.label) return -1;
|
||||
if (a.label > b.label) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Creates grouped options by grouping fields by field type
|
||||
export const getGroupedFieldOptions = (fields, filterField) => {
|
||||
if (!fields) {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldsByTypeMap = new Map();
|
||||
const groupedFieldOptions = [];
|
||||
|
||||
fields
|
||||
.filter(filterField)
|
||||
.forEach(field => {
|
||||
const fieldLabel = 'label' in field ? field.label : field.name;
|
||||
if (fieldsByTypeMap.has(field.type)) {
|
||||
const fieldsList = fieldsByTypeMap.get(field.type);
|
||||
fieldsList.push(field.name);
|
||||
fieldsList.push({ value: field.name, label: fieldLabel });
|
||||
fieldsByTypeMap.set(field.type, fieldsList);
|
||||
} else {
|
||||
fieldsByTypeMap.set(field.type, [field.name]);
|
||||
fieldsByTypeMap.set(field.type, [{ value: field.name, label: fieldLabel }]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const groupedFieldOptions = [];
|
||||
fieldsByTypeMap.forEach((fieldsList, fieldType) => {
|
||||
|
||||
const sortedOptions = fieldsList
|
||||
.sort(sortByLabel)
|
||||
.map(({ value, label }) => {
|
||||
return { value: value, label: label };
|
||||
});
|
||||
|
||||
groupedFieldOptions.push({
|
||||
label: fieldType,
|
||||
options: fieldsList.sort().map(fieldName => {
|
||||
return { value: fieldName, label: fieldName };
|
||||
})
|
||||
options: sortedOptions
|
||||
});
|
||||
});
|
||||
|
||||
groupedFieldOptions.sort((a, b) => {
|
||||
if (a.label < b.label) return -1;
|
||||
if (a.label > b.label) return 1;
|
||||
return 0;
|
||||
});
|
||||
groupedFieldOptions.sort(sortByLabel);
|
||||
|
||||
return groupedFieldOptions;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiFormRow
|
||||
} from '@elastic/eui';
|
||||
import { MultiFieldSelect } from './multi_field_select';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export class TooltipSelector extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={
|
||||
i18n.translate('xpack.maps.tooltipSelectorLabel', {
|
||||
defaultMessage: `Fields to display in tooltip`
|
||||
})
|
||||
}
|
||||
>
|
||||
<MultiFieldSelect
|
||||
placeholder={i18n.translate('xpack.maps.tooltipSelectorPlaceholder', {
|
||||
defaultMessage: `Select fields`
|
||||
})
|
||||
}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
fields={this.props.fields}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { TooltipSelector } from './tooltip_selector';
|
||||
|
||||
const defaultProps = {
|
||||
value: [],
|
||||
onChange: (()=>{}),
|
||||
fields: []
|
||||
};
|
||||
|
||||
describe('TooltipSelector', () => {
|
||||
|
||||
test('should create eui row component', async () => {
|
||||
const component = shallow(
|
||||
<TooltipSelector
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component)
|
||||
.toMatchSnapshot();
|
||||
});
|
||||
|
||||
});
|
|
@ -12,6 +12,7 @@ import { getEmsVectorFilesMeta } from '../../../../meta';
|
|||
import { EMSFileCreateSourceEditor } from './create_source_editor';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { getDataSourceLabel } from '../../../../../common/i18n_getters';
|
||||
import { UpdateSourceEditor } from './update_source_editor';
|
||||
|
||||
export class EMSFileSource extends AbstractVectorSource {
|
||||
|
||||
|
@ -24,22 +25,37 @@ export class EMSFileSource extends AbstractVectorSource {
|
|||
});
|
||||
static icon = 'emsApp';
|
||||
|
||||
static createDescriptor(id) {
|
||||
static createDescriptor({ id, tooltipProperties = [] }) {
|
||||
return {
|
||||
type: EMSFileSource.type,
|
||||
id: id
|
||||
id,
|
||||
tooltipProperties
|
||||
};
|
||||
}
|
||||
|
||||
static renderEditor({ onPreviewSource, inspectorAdapters }) {
|
||||
const onChange = (selectedId) => {
|
||||
const emsFileSourceDescriptor = EMSFileSource.createDescriptor(selectedId);
|
||||
const emsFileSourceDescriptor = EMSFileSource.createDescriptor({ id: selectedId });
|
||||
const emsFileSource = new EMSFileSource(emsFileSourceDescriptor, inspectorAdapters);
|
||||
onPreviewSource(emsFileSource);
|
||||
};
|
||||
return <EMSFileCreateSourceEditor onChange={onChange}/>;
|
||||
}
|
||||
|
||||
constructor(descriptor, inspectorAdapters) {
|
||||
super(EMSFileSource.createDescriptor(descriptor), inspectorAdapters);
|
||||
}
|
||||
|
||||
renderSourceSettingsEditor({ onChange }) {
|
||||
return (
|
||||
<UpdateSourceEditor
|
||||
onChange={onChange}
|
||||
tooltipProperties={this._descriptor.tooltipProperties}
|
||||
layerId={this._descriptor.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async _getEmsVectorFileMeta() {
|
||||
const emsFiles = await getEmsVectorFilesMeta();
|
||||
const meta = emsFiles.find((source => source.id === this._descriptor.id));
|
||||
|
@ -114,7 +130,25 @@ export class EMSFileSource extends AbstractVectorSource {
|
|||
}
|
||||
|
||||
canFormatFeatureProperties() {
|
||||
return true;
|
||||
return this._descriptor.tooltipProperties.length;
|
||||
}
|
||||
|
||||
async filterAndFormatPropertiesToHtml(properties) {
|
||||
const newProperties = {};
|
||||
const meta = await this._getEmsVectorFileMeta();
|
||||
for (const key in properties) {
|
||||
if (properties.hasOwnProperty(key) && this._descriptor.tooltipProperties.indexOf(key) > -1) {
|
||||
let newFieldName = key;
|
||||
for (let i = 0; i < meta.fields.length; i++) {
|
||||
if (meta.fields[i].name === key) {
|
||||
newFieldName = meta.fields[i].description;
|
||||
break;
|
||||
}
|
||||
}
|
||||
newProperties[newFieldName] = properties[key];
|
||||
}
|
||||
}
|
||||
return super.filterAndFormatPropertiesToHtml(newProperties);
|
||||
}
|
||||
|
||||
async getSupportedShapeTypes() {
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { TooltipSelector } from '../../../components/tooltip_selector';
|
||||
import { getEmsVectorFilesMeta } from '../../../../meta';
|
||||
|
||||
export class UpdateSourceEditor extends Component {
|
||||
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
tooltipProperties: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
};
|
||||
|
||||
state = {
|
||||
fields: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
this.loadFields();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
async loadFields() {
|
||||
let fields;
|
||||
try {
|
||||
const emsFiles = await getEmsVectorFilesMeta();
|
||||
const meta = emsFiles.find((source => source.id === this.props.layerId));
|
||||
fields = meta.fields.map(field => {
|
||||
return {
|
||||
type: 'string',
|
||||
name: field.name,
|
||||
label: field.description
|
||||
};
|
||||
});
|
||||
} catch(e) {
|
||||
//swallow this error. when a matching EMS-config cannot be found, the source already will have thrown errors during the data request. This will propagate to the vector-layer and be displayed in the UX
|
||||
fields = [];
|
||||
}
|
||||
if (this._isMounted) {
|
||||
this.setState({ fields: fields });
|
||||
}
|
||||
}
|
||||
|
||||
_onTooltipPropertiesSelect = (propertyNames) => {
|
||||
this.props.onChange({ propName: 'tooltipProperties', value: propertyNames });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TooltipSelector
|
||||
value={this.props.tooltipProperties}
|
||||
onChange={this._onTooltipPropertiesSelect}
|
||||
fields={this.state.fields}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -10,8 +10,8 @@ import {
|
|||
EuiFormRow,
|
||||
EuiSwitch,
|
||||
} from '@elastic/eui';
|
||||
import { MultiFieldSelect } from '../../../components/multi_field_select';
|
||||
import { SingleFieldSelect } from '../../../components/single_field_select';
|
||||
import { TooltipSelector } from '../../../components/tooltip_selector';
|
||||
|
||||
import { indexPatternService } from '../../../../kibana_services';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
@ -94,12 +94,11 @@ export class UpdateSourceEditor extends Component {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTooltipPropertiesSelect = (propertyNames) => {
|
||||
_onTooltipPropertiesChange = (propertyNames) => {
|
||||
this.props.onChange({ propName: 'tooltipProperties', value: propertyNames });
|
||||
};
|
||||
|
||||
onFilterByMapBoundsChange = event => {
|
||||
_onFilterByMapBoundsChange = event => {
|
||||
this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked });
|
||||
};
|
||||
|
||||
|
@ -194,23 +193,11 @@ export class UpdateSourceEditor extends Component {
|
|||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFormRow
|
||||
label={
|
||||
i18n.translate('xpack.maps.source.esSearch.fieldsLabel', {
|
||||
defaultMessage: `Fields to display in tooltip`
|
||||
})
|
||||
}
|
||||
>
|
||||
<MultiFieldSelect
|
||||
placeholder={i18n.translate('xpack.maps.source.esSearch.fieldsPlaceholder', {
|
||||
defaultMessage: `Select fields`
|
||||
})
|
||||
}
|
||||
value={this.props.tooltipProperties}
|
||||
onChange={this.onTooltipPropertiesSelect}
|
||||
fields={this.state.tooltipFields}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<TooltipSelector
|
||||
value={this.props.tooltipProperties}
|
||||
onChange={this._onTooltipPropertiesChange}
|
||||
fields={this.state.tooltipFields}
|
||||
/>
|
||||
|
||||
<EuiFormRow>
|
||||
<EuiSwitch
|
||||
|
@ -221,7 +208,7 @@ export class UpdateSourceEditor extends Component {
|
|||
|
||||
}
|
||||
checked={this.props.filterByMapBounds}
|
||||
onChange={this.onFilterByMapBoundsChange}
|
||||
onChange={this._onFilterByMapBoundsChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
|
|
|
@ -623,7 +623,7 @@ export class VectorLayer extends AbstractLayer {
|
|||
}
|
||||
|
||||
canShowTooltip() {
|
||||
return this.isVisible() && this._source.canFormatFeatureProperties();
|
||||
return this.isVisible() && (this._source.canFormatFeatureProperties() || this._joins.length);
|
||||
}
|
||||
|
||||
getFeatureById(id) {
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -40,7 +40,7 @@ export const getWebLogsSavedObjects = () => {
|
|||
}),
|
||||
'description': '',
|
||||
'mapStateJSON': '{"zoom":3.64,"center":{"lon":-88.92107,"lat":42.16337},"timeFilters":{"from":"now-7d","to":"now"},"refreshConfig":{"isPaused":true,"interval":0},"query":{"language":"kuery","query":""}}',
|
||||
'layerListJSON': '[{"id":"0hmz5","alpha":1,"sourceDescriptor":{"type":"EMS_TMS","id":"road_map"},"visible":true,"style":{"type":"TILE","properties":{}},"type":"TILE","minZoom":0,"maxZoom":24},{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src","indexPatternRefName":"layer_1_join_0_index_pattern"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"],"indexPatternRefName":"layer_2_source_index_pattern"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}],"indexPatternRefName":"layer_3_source_index_pattern"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]',
|
||||
'layerListJSON': '[{"id":"0hmz5","alpha":1,"sourceDescriptor":{"type":"EMS_TMS","id":"road_map"},"visible":true,"style":{"type":"TILE","properties":{}},"type":"TILE","minZoom":0,"maxZoom":24},{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries", "tooltipProperties":["name"]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src","indexPatternRefName":"layer_1_join_0_index_pattern"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"],"indexPatternRefName":"layer_2_source_index_pattern"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}],"indexPatternRefName":"layer_3_source_index_pattern"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]',
|
||||
'uiStateJSON': '{"isDarkMode":false}',
|
||||
'bounds': { 'type': 'envelope', 'coordinates': [[-124.45342, 54.91445], [-53.38872, 26.21461]] }
|
||||
}
|
||||
|
|
|
@ -5352,8 +5352,8 @@
|
|||
"xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "ダイナミックデータフィルターは無効です",
|
||||
"xpack.maps.source.esSearch.disableFilterByMapBoundsTurnOnMsg": "ドキュメント数が増えると思われる場合はダイナミックフィルターをオンにしてください。",
|
||||
"xpack.maps.source.esSearch.extentFilterLabel": "マップの表示範囲でデータを動的にフィルタリングします。",
|
||||
"xpack.maps.source.esSearch.fieldsLabel": "ツールヒントに表示するフィールド",
|
||||
"xpack.maps.source.esSearch.fieldsPlaceholder": "フィールドを選択",
|
||||
"xpack.maps.tooltipSelectorLabel": "ツールヒントに表示するフィールド",
|
||||
"xpack.maps.tooltipSelectorPlaceholder": "フィールドを選択",
|
||||
"xpack.maps.source.esSearch.geofieldLabel": "地理空間フィールド",
|
||||
"xpack.maps.source.esSearch.geoFieldLabel": "地理空間フィールド",
|
||||
"xpack.maps.source.esSearch.geoFieldTypeLabel": "地理空間フィールドタイプ",
|
||||
|
@ -9895,4 +9895,4 @@
|
|||
"xpack.watcher.watchActionsTitle": "条件が満たされた際に {watchActionsCount, plural, one{# アクション} other {# アクション}} を実行します",
|
||||
"xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue