Skip to content

Commit

Permalink
New base input composable to abstract common editor functionality
Browse files Browse the repository at this point in the history
This commit brings an important change to the application in terms
of abstracting common editor functionality into a composable. While
some editors consist of a single base component, such a text field
or a select, it could be quite common that an editor component might
have a set of interdependent components with (possibly complex)
interdependent logic. Ideally, these complexities should be contained
within the editor component itself, and should not be exposed to the
parent or rest of the application. Similarly, the effort for a developer
to create their own custom editor component should ideally be kept low
and they shouldn't have to use all of the app-specific inner logic.
Therefore, it made sense to:
- create a simplified top-down API for new custom editors
- provide 'base class' functionality that new custom editors can import

The simplified API basically means that custom editors do not have to
receive, understand, or manipulate the form data structure anymore in
order to achieve two-way binding of their specific data values. The
parent component now 'v-model's the correct value within the formData
object, and the custom component is (for all intents and purposes) blind
to it.

The base class functionality is divided into a few steps:
- custom editors can wrap their subcomponents into a top-level v-input
- the v-input

- the v-input is the interface for 2-way binding of the model value from
  the parent, and between the v-input and its subcomponents, by 'v-model'
  ing the 'internalValue'
- the 'useBaseInput' composable should be imported and used by a custom
  component. This provides the internalValue and the computed property
  getter and setter methods for updating 'internalValue'. For this reason
  the custom editor should provide two main functions as arguments to the
  composable:
  - a function to parse the 'v-model'ed values of the subcomponents from
    'internalValue', and
  - a function to determine 'internalValue' from the values of the subcomponents
  The composable also provides an object 'subValues' to which arbitrary
  keys can be added with which to 'v-model' the subcomponent values.
- The custom component should call defineEmits and pass the result to the
  composable, in order to establish a function that updates the parent on
  a local 'internalValue' change.

As a demonstration of how this can be implemented, the DateTimePickerEditor
and URIeditor components were updated according to this new framework.
  • Loading branch information
jsheunis committed Aug 22, 2024
1 parent d300b52 commit b918ebd
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 86 deletions.
42 changes: 27 additions & 15 deletions src/components/DateTimePickerEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
<v-dialog max-width="500">
<template v-slot:activator="{ props: activatorProps }">
<v-input
:rules="rules"
ref="fieldRef"
:id="inputId">
v-model="internalValue"
:rules="rules"
ref="fieldRef"
:id="inputId"
>
<v-btn
v-bind="activatorProps"
:text="formData[props.node_uid].at(-1)[props.triple_uid].at(-1) ? formData[props.node_uid].at(-1)[props.triple_uid].at(-1).toISOString().split('T')[0] : 'Select a date'"
:text="internalValue ? internalValue : 'Select a date'"
></v-btn>
</v-input>
</template>
Expand All @@ -16,7 +18,7 @@
<v-card title="Date">
<v-date-picker
show-adjacent-months
v-model="triple_object"
v-model="subValues.picked_date"
validate-on="lazy input"
></v-date-picker>
<v-card-actions>
Expand All @@ -33,8 +35,10 @@
import {inject, computed} from 'vue'
import { useRules } from '../composables/rules'
import { useRegisterRef } from '../composables/refregister';
import { useBaseInput } from '@/composables/base';
const props = defineProps({
modelValue: String,
property_shape: Object,
node_uid: String,
triple_uid: String
Expand All @@ -44,16 +48,24 @@
const inputId = `input-${Date.now()}`;
const { fieldRef } = useRegisterRef(inputId, props);
const triple_object = computed({
get() {
return formData[props.node_uid].at(-1)[props.triple_uid].at(-1);
},
set(value) {
const node_idx = formData[props.node_uid].length - 1
const triple_idx = formData[props.node_uid][node_idx][props.triple_uid].length - 1
formData[props.node_uid][node_idx][props.triple_uid][triple_idx] = value;
}
});
const emit = defineEmits(['update:modelValue']);
const { subValues, internalValue } = useBaseInput(
props,
emit,
valueParser,
valueCombiner
);
function valueParser(value) {
// Parsing internalValue into ref values for separate subcomponent(s)
return {picked_date: value}
}
function valueCombiner(values) {
if (values.picked_date) return values.picked_date.toISOString().split('T')[0]
return null
}
</script>

<script>
Expand Down
3 changes: 2 additions & 1 deletion src/components/PropertyShapeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
</v-col>
<v-col cols="8">

<v-row no-gutters v-for="(triple, triple_idx) in formData[props.node_uid].at(-1)[my_uid]" :key="triple_idx">
<v-row no-gutters v-for="(triple, triple_idx) in formData[props.node_uid].at(-1)[my_uid]" :key="props.node_uid + '-' + my_uid + '-' + triple_idx">
<v-col cols="9">
<component
v-model="formData[props.node_uid].at(-1)[my_uid][triple_idx]"
:is="matchedComponent"
:property_shape="property_shape"
:node_uid="props.node_uid"
Expand Down
149 changes: 94 additions & 55 deletions src/components/URIEditor.vue
Original file line number Diff line number Diff line change
@@ -1,51 +1,53 @@
<!-- TODO: investigate https://vuetifyjs.com/en/components/inputs/
to combine below into a single input component -->

<!-- The v-bind="attrs" and v-on="listeners" directives in the v-input component are used to ensure that any attributes and event listeners passed to the custom URIeditor component from its parent are properly forwarded to the underlying v-input component. -->

<template>
<v-input>
<v-input
v-model="internalValue"
:rules="rules"
ref="fieldRef"
:id="inputId"
hide-details="auto"
style="margin-bottom: 1em;"
>
<v-row justify="start" no-gutters>
<v-col cols="9">
<span v-if="enterURI">
<span v-if="!enterCURIE">
<v-text-field
v-model="subValues.uri_text"
label="add URI text"
density="compact"
variant="outlined"
hide-details="auto"
></v-text-field>
</span>
<span v-else>
<v-row justify="start" no-gutters>
<v-col cols="5">
<v-select
label="prefix"
v-model="triple_prefix"
v-model="subValues.uri_prefix"
:items="prefixOptions"
density="compact"
variant="outlined"
style="margin-bottom: 0; padding-bottom: 0"
hide-details="auto"
></v-select>
</v-col>
<v-col cols="7">
<v-text-field
:ref="props.triple_uid + '-' + props.triple_idx"
v-model="triple_property"
v-model="subValues.uri_path"
density="compact"
style="margin-bottom: 0; padding-bottom: 0"
variant="outlined"
type="url"
label="add text"
validate-on="lazy input"
:rules="rules"
hide-details="auto"
>
</v-text-field>
</v-col>
</v-row>

</span>
</v-col>
<v-col>
<v-checkbox v-model="enterURI" density="compact" label="URI" style="margin-top:0; margin-left: 0.5em; padding-top:0"></v-checkbox>
<v-checkbox v-model="enterCURIE" density="compact" label="CURIE" hide-details="true" style="margin-top:0; margin-left: 0.5em; padding-top:0;"></v-checkbox>
</v-col>
</v-row>
</v-input>
Expand All @@ -54,21 +56,39 @@
<script setup>
import { inject, computed, ref, watch, onMounted} from 'vue'
import { useRules } from '../composables/rules'
import { toCURIE } from '../modules/utils';
import { toCURIE, isObject } from '../modules/utils';
import { useRegisterRef } from '../composables/refregister';
import { useBaseInput } from '@/composables/base';
const props = defineProps({
modelValue: String,
property_shape: Object,
node_uid: String,
triple_uid: String,
triple_idx: Number
})
const formData = inject('formData');
const { rules } = useRules(props.property_shape)
rules.value.push(
value => {
const uriRegex = /^([a-zA-Z][a-zA-Z0-9+-.]*):(?:\/\/((?:[a-zA-Z0-9\-._~%!$&'()*+,;=]+@)?(?:\[(?:[A-Fa-f0-9:.]+)\]|(?:[a-zA-Z0-9\-._~%]+))(?:\:[0-9]+)?)?)?((?:\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@]*)*)(?:\?[a-zA-Z0-9\-._~%!$&'()*+,;=:@/?]*)?(?:\#[a-zA-Z0-9\-._~%!$&'()*+,;=:@/?]*)?$/;
if (uriRegex.test(value)) return true
return 'This is not a valid URI of type XSD:anyURI'
}
)
const inputId = `input-${Date.now()}`;
const { fieldRef } = useRegisterRef(inputId, props);
const allPrefixes = inject('allPrefixes');
const selectedPrefix = ref('')
const enteredValue = ref('')
// const prefixRules = ref([])
const enterURI = ref(false)
const enterCURIE = ref(false)
const emit = defineEmits(['update:modelValue']);
const { subValues, internalValue } = useBaseInput(
props,
emit,
valueParser,
valueCombiner
);
// ----------------- //
// Lifecycle methods //
Expand Down Expand Up @@ -96,42 +116,61 @@
return prefixes.sort((a, b) => a.title.localeCompare(b.title))
})
const triple_components = computed(() => {
var triple_obj = formData[props.node_uid].at(-1)[props.triple_uid][props.triple_idx]
return toCURIE(triple_obj, allPrefixes, "parts")
})

watch(triple_components, (newValue) => {
selectedPrefix.value = newValue ? newValue.prefix : '';
enteredValue.value = newValue ? newValue.property : '';
}, { immediate: true });

const triple_prefix = computed({
get() {
return selectedPrefix.value;
},
set(value) {
selectedPrefix.value = value;
updateFormData();
}
});

const triple_property = computed({
get() {
return enteredValue.value;
},
set(value) {
enteredValue.value = value;
updateFormData();
}
});

const updateFormData = () => {
formData[props.node_uid].at(-1)[props.triple_uid][props.triple_idx] = `${allPrefixes[selectedPrefix.value]}${enteredValue.value}`;
};



// --------- //
// Functions //
// --------- //
function valueParser(value) {
// Parsing internalValue into ref values for separate subcomponent(s)
// internalValue is a URI
// - if internalValue is null, set all to null or empty strings
// - for the text field: set directly from internalValue
// - for the curie:
// - call toCurie in order to split it up into prefix and path
// - if toCurie not possible (i.e. unknown prefix), don't set the prefix nor path (or set to null or empty string?)
// whether the switch is set to uri or curie is not important here
var URItext, URIprefix, URIpath
if (!value) {
URItext = '';
URIprefix = null;
URIpath = '';
} else {
URItext = value
var curieparts = toCURIE(value, allPrefixes, "parts")
if ( isObject(curieparts)) {
URIprefix = curieparts.prefix;
URIpath = curieparts.property;
} else {
URIprefix = null;
URIpath = '';
}
}
return {
uri_text: URItext,
uri_prefix: URIprefix,
uri_path: URIpath
}
}
function valueCombiner(values) {
// Determing internalValue (a URI) from subvalues/subcomponents
// if the switch is set to URI:
// - return subValues.uri_text
// if switch set to CURIE (default):
// - if prefix not selected
if (!enterCURIE.value) {
return values.uri_text ?? ''
} else {
if (values.uri_prefix !== null) {
return `${allPrefixes[values.uri_prefix] ?? ''}${values.uri_path ?? ''}`
} else {
return `${values.uri_path ?? ''}`
}
}
}
</script>
<script>
Expand Down
64 changes: 49 additions & 15 deletions src/composables/base.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,57 @@
// base.js
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { v4 as uuidv4 } from 'uuid';
// composables/base.js
import { ref, watch, computed } from 'vue';

// by convention, composable function names start with "use"
export function useBaseComponent() {
/**
* Composable for managing the state and behavior of the BaseEditor component.
*
* @param {Object} props - The props passed to the custom input component.
* @param {Function} emit - The emit function to trigger events.
* @param {Function} valueParser - A function to parse the modelValue into subcomponent values.
* @param {Function} valueCombiner - A function to combine subcomponent values into the modelValue.
* @returns {Object} - Returns an object containing reactive references and methods for managing the input state.
*/

var my_uid = ref(null)
export function useBaseInput(props, emit, valueParser, valueCombiner) {
/**
* Reactive object to hold the individual values of subcomponents.
* @type {Object}
*/
const subValues = ref(valueParser(props.modelValue) || {});

function assignUUID() {
my_uid = uuidv4();
}
/**
* Computed property to manage the internal value of the custom input component.
* - The getter combines the subcomponent values into the modelValue.
* - The setter parses the modelValue back into subcomponent values.
*/
const internalValue = computed({
get() {
return valueCombiner(subValues.value);
},
set(value) {
subValues.value = valueParser(value);
}
});

/**
* Watcher for the parent component's modelValue.
* Updates internal values when modelValue changes.
*/
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== internalValue.value) {
internalValue.value = newValue;
}
}
);

onMounted(() => {
console.log("Component mounted, assigning UUID from composable code:\n")
assignUUID()
console.log(my_uid)
// Emit updates to parent
watch(internalValue, (newValue) => {
emit('update:modelValue', newValue);
});

return {
my_uid
}
subValues,
internalValue,
};
}
4 changes: 4 additions & 0 deletions src/modules/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,8 @@ function objectFlip(obj) {
ret[obj[key]] = key;
return ret;
}, {});
}

export function isObject(val) {
return typeof val === 'object' && !Array.isArray(val) && val !== null
}

0 comments on commit b918ebd

Please sign in to comment.