Skip to content

Commit

Permalink
custom asset field editor ux redesign
Browse files Browse the repository at this point in the history
  • Loading branch information
cconard96 committed Dec 18, 2024
1 parent b56a78b commit 4d6efa6
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 225 deletions.
34 changes: 0 additions & 34 deletions ajax/asset/assetdefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,40 +63,6 @@
'count' => count($all_fields)
], JSON_THROW_ON_ERROR);
return;
} else if ($_REQUEST['action'] === 'get_field_placeholder' && isset($_POST['fields']) && is_array($_POST['fields'])) {
header("Content-Type: application/json; charset=UTF-8");
$custom_field = new CustomFieldDefinition();
$results = [];
foreach ($_POST['fields'] as $field) {
if ($field['customfields_id'] > 0) {
if (!$custom_field->getFromDB($field['customfields_id'])) {
throw new NotFoundHttpException();
}
} else {
$custom_field->fields['system_name'] = '';
$custom_field->fields['label'] = $field['label'];
$custom_field->fields['type'] = $field['type'];
$custom_field->fields['itemtype'] = 'Computer'; // Doesn't matter what it is as long as it's not empty
$custom_field->fields['default_value'] = '';

$asset_definition = new AssetDefinition();
if (!$asset_definition->getFromDB($field['assetdefinitions_id'])) {
throw new NotFoundHttpException();
}
$fields_display = $asset_definition->getDecodedFieldsField();
foreach ($fields_display as $field_display) {
if ($field_display['key'] === $field['key']) {
$custom_field->fields['field_options'] = $field_display['field_options'] ?? [];
break;
}
}
}
$custom_field->fields['field_options'] = array_merge($custom_field->fields['field_options'] ?? [], $field['field_options'] ?? []);
$custom_field->fields['field_options']['disabled'] = true;
$results[$field['key']] = $custom_field->getFieldType()->getFormInput('', null);
}
echo json_encode($results, JSON_THROW_ON_ERROR);
return;
} else if ($_REQUEST['action'] === 'get_core_field_editor') {
header("Content-Type: text/html; charset=UTF-8");
$asset_definition = new AssetDefinition();
Expand Down
56 changes: 30 additions & 26 deletions js/src/vue/CustomObject/FieldPreview/Field.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,31 @@
type: Number,
default: -1,
},
label_classes: {
type: String,
default: 'col-form-label cursor-grab col-xxl-5 text-xxl-end',
field_options: {
type: Object,
default: () => ({}),
},
field_classes: {
type: String,
default: 'col-xxl-7 field-container btn-group shadow-none',
is_active: {
type: Boolean,
default: true,
},
wrapper_classes: {
type: String,
default: 'form-field row flex-grow-1',
},
});
const sortable_classes = computed(() => {
return props.wrapper_classes.split(' ').filter((cls) => cls.startsWith('col-')).join(' ');
});
</script>

<template>
<div :class="`sortable-field align-items-center p-1 ${(!!$slots.field_preview) ? sortable_classes : 'col-12 col-sm-6'}`"
:data-key="field_key" :data-customfield-id="customfields_id" :style="`display: ${(!!$slots.field_preview) ? 'flex' : 'none'};`">
<div :class="`sortable-field align-items-center ${field_options.full_width ? 'col-12' : 'col-12 col-sm-6'}`"
:data-key="field_key" :data-customfield-id="customfields_id">
<input type="hidden" name="fields_display[]" :value="field_key" />
<slot name="field_options"></slot>
<div :class="wrapper_classes">
<label :class="label_classes">
<slot name="field_label"></slot>
<div :class="`form-field row flex-grow-1 m-2`">
<div class="col-auto align-content-center">
<i class="ti ti-grip-vertical sort-handle"></i>
</div>
<div class="col py-2">
<slot name="field_markers"></slot>
<i class="ti ti-grip-vertical sort-handle align-middle"></i>
</label>
<div :class="field_classes">
<slot name="field_preview"></slot>
<slot name="field_label"></slot>
</div>
<div v-if="is_active" class="col-auto btn-group shadow-none field-actions">
<button type="button" class="btn btn-ghost-secondary btn-sm edit-field" :title="__('Edit')">
<i class="ti ti-pencil"></i>
</button>
Expand All @@ -51,8 +44,19 @@
</template>

<style scoped>
.sortable-field .btn-group .select2-container {
flex-basis: auto;
width: 100% !important;
.form-field {
border: var(--tblr-border-width) solid var(--tblr-border-color);
border-radius: var(--tblr-border-radius);
& > .col {
border-left: 1px solid var(--tblr-border-color);
}
& > .field-actions {
visibility: hidden;
}
&:hover > .field-actions {
visibility: visible;
}
}
</style>
179 changes: 60 additions & 119 deletions js/src/vue/CustomObject/FieldPreview/FieldDisplay.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
<script setup>
import {onMounted, ref, computed, reactive, watch} from 'vue';
import {onMounted, computed, reactive, watch, useTemplateRef, nextTick} from 'vue';
import Field from "./Field.vue";
import Sidebar from "./Sidebar.vue";
const props = defineProps({
items_id: Number,
toolbar_el: String,
all_fields: Object,
fields_display: Array,
add_edit_fn: String,
can_create_fields: Boolean,
});
const initial_all_fields = props.all_fields;
const fields_display = props.fields_display;
const toolbar_el = $(props.toolbar_el);
//TODO Vue 3.5: useTemplateRef('component_root')
const component_root = ref(null);
const component_root = useTemplateRef('component_root');
const sortable_fields_container = computed(() => {
return $(component_root.value).parent();
});
Expand All @@ -26,75 +25,20 @@
*/
const sortable_fields = reactive(new Map());
function getSelectedField(key) {
let selected_field = initial_all_fields[key];
if (selected_field === undefined) {
const opt = $(`select[name="new_field"] option[value="${key}"]`);
if (opt.length > 0) {
selected_field = {
text: opt.text(),
customfields_id: opt.attr('data-customfield-id') ?? -1,
};
}
}
return selected_field;
}
/**
* Fetch the field preview for the given fields and update the fields in the sortable list
* @param {{key: string, selected_field: {}}[]} fields
*/
function appendFieldPreview(fields) {
const payload = {
action: 'get_field_placeholder',
assetdefinitions_id: props.items_id,
fields: []
};
fields.forEach(({key, selected_field}) => {
if (!sortable_fields.has(key)) {
sortable_fields.set(key, {
key: key,
label: selected_field.text ?? selected_field,
field_options: fields_display.find((field) => field.key === key)?.field_options ?? {},
customfields_id: selected_field.customfields_id ?? -1,
});
}
const field_options = {};
for (const [name, value] of Object.entries(sortable_fields.get(key).field_options)) {
field_options[name] = value;
}
payload.fields.push({
assetdefinitions_id: props.items_id,
customfields_id: selected_field.customfields_id ?? -1,
key: key,
label: sortable_fields.get(key).label,
type: selected_field.type ?? '',
field_options: field_options,
function refreshSortables() {
nextTick(() => {
// Need to wait for the DOM changes to be applied
window.sortable('#sortable-fields', {
items: '.sortable-field',
forcePlaceholderSize: false,
acceptFrom: '.fields-sidebar, #sortable-fields',
});
window.sortable('.fields-sidebar', {
items: '.sortable-field',
forcePlaceholderSize: false,
acceptFrom: '#sortable-fields',
})
});
$.ajax({
method: 'POST',
url: `${CFG_GLPI.root_doc}/ajax/asset/assetdefinition.php`,
data: payload
}).then((data) => {
if (typeof data !== 'object') {
return;
}
fields.forEach(({key}) => {
updateFieldPreview(key, data[key]);
});
});
}
function updateFieldPreview(key, data) {
const placeholder_el = $(`<div>${data}</div>`);
const sortable_field = sortable_fields.get(key);
sortable_field.preview_html = placeholder_el.find('.field-container').html();
sortable_field.label_classes = `${placeholder_el.find('label').attr('class')} cursor-grab`;
sortable_field.field_classes = `${placeholder_el.find('.field-container').attr('class')} btn-group shadow-none`;
sortable_field.wrapper_classes = `${placeholder_el.find('.form-field').attr('class')} flex-grow-1`;
}
/**
Expand All @@ -109,30 +53,47 @@
if (selected_fields_data !== undefined && selected_fields_data[key] !== undefined) {
selected_field = selected_fields_data[key];
} else {
toolbar_el.find('select[name="new_field"]').val(key).trigger('change');
selected_field = getSelectedField(key);
selected_field = props.all_fields[key];
}
if (selected_field === undefined) {
return;
}
preview_data.push({key: key, selected_field: selected_field});
});
appendFieldPreview(preview_data);
// Clear the select2 value
toolbar_el.find('select[name="new_field"]').val('').trigger('change');
preview_data.forEach(({key, selected_field}) => {
if (!sortable_fields.has(key)) {
sortable_fields.set(key, {
key: key,
label: selected_field.text ?? selected_field,
field_options: fields_display.find((field) => field.key === key)?.field_options ?? {},
customfields_id: selected_field.customfields_id ?? -1,
});
}
});
refreshSortables();
}
function removeField(key) {
// remove the field from sortable list
sortable_fields.delete(key);
refreshSortables();
}
/**
* Refresh the data in the all_fields object
*/
function refreshAllFields() {
const url = `ajax/asset/assetdefinition.php?action=get_all_fields&assetdefinitions_id=${props.items_id}`;
$.get(url, (data) => {
console.log(data);
});
}
onMounted(() => {
//for each field in fields_display, add it to the list using the template and slot
appendField(fields_display.map((field) => field.key));
const sortable_container = $('#sortable-fields');
const new_field_dropdown = toolbar_el.find('select[name="new_field"]');
sortable_container.on('dragenter', () => {
const sort_el = $('.sortable-field.sortable-dragging');
Expand All @@ -142,15 +103,6 @@
sortable_container.find('.sortable-placeholder').attr('class', `sortable-placeholder ${classes_to_copy}`);
});
// add field action
$('#add-field').on('click', () => {
//get select2 value
const field_key = new_field_dropdown.val();
if (field_key && field_key !== 0 ) {
appendField([field_key]);
}
});
$(component_root.value).on('click', '.edit-field', (e) => {
const field_el = $(e.target).closest('.sortable-field');
const field_id = field_el.attr('data-customfield-id');
Expand Down Expand Up @@ -219,15 +171,8 @@
const form_data = new FormData(e.target);
const field_key = `custom_${form_data.get('system_name')}`;
if (btn_submit.attr('name') === 'add') {
new_field_dropdown.data('select2').dataAdapter.query('', (data) => {
data.results.forEach((result) => {
if (result.id === field_key) {
appendField([field_key], {[field_key]: result});
}
});
});
} else if (btn_submit.attr('name') === 'update') {
refreshAllFields();
if (btn_submit.attr('name') === 'add' || btn_submit.attr('name') === 'update') {
// Reload preview
appendField([field_key], {[field_key]: sortable_fields.get(field_key)});
} else if (btn_submit.attr('name') === 'purge') {
Expand All @@ -237,10 +182,6 @@
});
watch(sortable_fields, () => {
window.sortable('#sortable-fields', {
items: '.sortable-field',
forcePlaceholderSize: false,
});
// If only one field remains, disable the remove button
$(component_root.value).find('.hide-field')
.prop('disabled', sortable_fields.size === 1)
Expand All @@ -250,30 +191,30 @@
</script>
<template>
<div class="col-12 col-xxl-12 flex-column" ref="component_root">
<div class="col-12 col-xxl-12 flex-column px-n3" ref="component_root">
<input type="hidden" name="_update_fields_display" value="1" />
<input type="hidden" name="fields_display" value="" />
<div class="d-flex flex-row flex-wrap flex-xl-nowrap">
<div class="row flex-row align-items-start flex-grow-1">
<div class="user-select-none row flex-row" id="sortable-fields">
<Field v-for="[field_key, sortable_field] of sortable_fields" :key="field_key"
:field_key="field_key" :customfields_id="sortable_field.customfields_id" :label_classes="sortable_field.label_classes"
:field_classes="sortable_field.field_classes" :wrapper_classes="sortable_field.wrapper_classes">
<template v-slot:field_label>{{ sortable_field.label }}</template>
<template v-slot:field_markers>
<span v-if="(sortable_field.field_options.required ?? '0').toString() === '1'" class="required">*</span>
<i v-if="(sortable_field.field_options.readonly ?? '0').toString() === '1'" class="ti ti-pencil-off ms-2" :title="__('Readonly')"></i>
</template>
<template v-slot:field_options>
<template v-for="(field_option_value, field_option_name) in sortable_field.field_options" :key="field_option_name">
<input type="hidden" :name="`field_options[${field_key}][${field_option_name}]`" :value="field_option_value" />
<div class="row flex-row align-items-start flex-grow-1 d-flex">
<div class="col">
<div class="user-select-none row flex-row" id="sortable-fields">
<Field v-for="[field_key, sortable_field] of sortable_fields" :key="field_key"
:field_key="field_key" :customfields_id="sortable_field.customfields_id" :field_options="sortable_field.field_options">
<template v-slot:field_label>{{ sortable_field.label }}</template>
<template v-slot:field_markers>
<span v-if="(sortable_field.field_options.required ?? '0').toString() === '1'" class="required">*</span>
<i v-if="(sortable_field.field_options.readonly ?? '0').toString() === '1'" class="ti ti-pencil-off ms-2" :title="__('Readonly')"></i>
</template>
<template v-slot:field_options>
<template v-for="(field_option_value, field_option_name) in sortable_field.field_options" :key="field_option_name">
<input type="hidden" :name="`field_options[${field_key}][${field_option_name}]`" :value="field_option_value" />
</template>
</template>
</template>
<template v-slot:field_preview v-if="sortable_field.preview_html">
<div v-html="sortable_field.preview_html" style="display: contents"></div>
</template>
</Field>
</Field>
</div>
</div>
<Sidebar :all_fields="all_fields" :sortable_fields="sortable_fields"></Sidebar>
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit 4d6efa6

Please sign in to comment.