From b1c63032b461f5c0f71ee15ef6dffcee40484a03 Mon Sep 17 00:00:00 2001 From: Tristan K Date: Thu, 10 Aug 2023 13:19:43 -0600 Subject: [PATCH 1/6] refactor component logic into seperate files --- .../schema/ui/apos/logic/AposArrayEditor.js | 361 ++++++++++++++++++ .../schema/ui/apos/logic/AposInputArea.js | 89 +++++ .../schema/ui/apos/logic/AposInputArray.js | 257 +++++++++++++ .../ui/apos/logic/AposInputAttachment.js | 81 ++++ .../schema/ui/apos/logic/AposInputBoolean.js | 48 +++ .../ui/apos/logic/AposInputCheckboxes.js | 68 ++++ .../schema/ui/apos/logic/AposInputColor.js | 98 +++++ .../ui/apos/logic/AposInputDateAndTime.js | 49 +++ .../schema/ui/apos/logic/AposInputObject.js | 86 +++++ .../schema/ui/apos/logic/AposInputPassword.js | 41 ++ .../schema/ui/apos/logic/AposInputRadio.js | 29 ++ .../schema/ui/apos/logic/AposInputRange.js | 60 +++ .../ui/apos/logic/AposInputRelationship.js | 262 +++++++++++++ .../schema/ui/apos/logic/AposInputSelect.js | 41 ++ .../schema/ui/apos/logic/AposInputSlug.js | 278 ++++++++++++++ .../schema/ui/apos/logic/AposInputString.js | 170 +++++++++ .../schema/ui/apos/logic/AposInputWrapper.js | 118 ++++++ .../schema/ui/apos/logic/AposSchema.js | 281 ++++++++++++++ .../schema/ui/apos/logic/AposSearchList.js | 249 ++++++++++++ 19 files changed, 2666 insertions(+) create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposArrayEditor.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputArea.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputArray.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputAttachment.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputBoolean.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputDateAndTime.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputObject.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputPassword.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputRange.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputSelect.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputString.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js create mode 100644 modules/@apostrophecms/schema/ui/apos/logic/AposSearchList.js diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposArrayEditor.js b/modules/@apostrophecms/schema/ui/apos/logic/AposArrayEditor.js new file mode 100644 index 0000000000..742bed31b9 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposArrayEditor.js @@ -0,0 +1,361 @@ +import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin'; +import AposEditorMixin from 'Modules/@apostrophecms/modal/mixins/AposEditorMixin'; +import cuid from 'cuid'; +import { klona } from 'klona'; +import { get } from 'lodash'; +import { detectDocChange } from 'Modules/@apostrophecms/schema/lib/detectChange'; + +export default { + name: 'AposArrayEditor', + mixins: [ + AposModifiedMixin, + AposEditorMixin + ], + props: { + items: { + required: true, + type: Array + }, + field: { + required: true, + type: Object + }, + serverError: { + type: Object, + default: null + }, + docId: { + type: String, + default: null + }, + parentFollowingValues: { + type: Object, + default: null + } + }, + emits: [ 'modal-result', 'safe-close' ], + data() { + // Automatically add `_id` to default items + const items = this.items.map(item => ({ + ...item, + _id: item._id || cuid() + })); + + return { + currentId: null, + currentDoc: null, + modal: { + active: false, + type: 'overlay', + showModal: false + }, + modalTitle: { + key: 'apostrophe:editType', + type: this.$t(this.field.label) + }, + titleFieldChoices: null, + // If we don't clone, then we're making + // permanent modifications whether the user + // clicks save or not + next: klona(items), + original: klona(items), + triggerValidation: false, + minError: false, + maxError: false, + cancelDescription: 'apostrophe:arrayCancelDescription' + }; + }, + computed: { + moduleOptions() { + return window.apos.schema || {}; + }, + itemError() { + return this.currentDoc && this.currentDoc.hasErrors; + }, + valid() { + return !(this.minError || this.maxError || this.itemError); + }, + maxed() { + return (this.field.max !== undefined) && (this.next.length >= this.field.max); + }, + schema() { + // For AposDocEditorMixin + return (this.field.schema || []).filter(field => apos.schema.components.fields[field.type]); + }, + countLabel() { + return this.$t('apostrophe:numberAdded', { + count: this.next.length + }); + }, + // Here in the array editor we use effectiveMin to factor in the + // required property because there is no other good place to do that, + // unlike the input field wrapper which has a separate visual + // representation of "required". + minLabel() { + if (this.effectiveMin) { + return this.$t('apostrophe:minUi', { + number: this.effectiveMin + }); + } else { + return false; + } + }, + maxLabel() { + if ((typeof this.field.max) === 'number') { + return this.$t('apostrophe:maxUi', { + number: this.field.max + }); + } else { + return false; + } + }, + effectiveMin() { + if (this.field.min) { + return this.field.min; + } else if (this.field.required) { + return 1; + } else { + return 0; + } + }, + currentDocServerErrors() { + let serverErrors = null; + ((this.serverError && this.serverError.data && this.serverError.data.errors) || []).forEach(error => { + const [ _id, fieldName ] = error.path.split('.'); + if (_id === this.currentId) { + serverErrors = serverErrors || {}; + serverErrors[fieldName] = error; + } + }); + return serverErrors; + } + }, + async mounted() { + this.modal.active = true; + if (this.next.length) { + this.select(this.next[0]._id); + } + if (this.serverError && this.serverError.data && this.serverError.data.errors) { + const first = this.serverError.data.errors[0]; + const [ _id, name ] = first.path.split('.'); + await this.select(_id); + const aposSchema = this.$refs.schema; + await this.nextTick(); + aposSchema.scrollFieldIntoView(name); + } + this.titleFieldChoices = await this.getTitleFieldChoices(); + }, + methods: { + async select(_id) { + if (this.currentId === _id) { + return; + } + if (await this.validate(true, false)) { + // Force the array editor to totally reset to avoid in-schema + // animations when switching (e.g., the relationship input). + this.currentDocToCurrentItem(); + this.currentId = null; + await this.nextTick(); + this.currentId = _id; + this.currentDoc = { + hasErrors: false, + data: this.next.find(item => item._id === _id) + }; + this.triggerValidation = false; + } + }, + update(items) { + // Take care to use the same items in order to avoid + // losing too much state inside draggable, otherwise + // drags fail + this.next = items.map(item => this.next.find(_item => item._id === _item._id)); + if (this.currentId) { + if (!this.next.find(item => item._id === this.currentId)) { + this.currentId = null; + this.currentDoc = null; + } + } + this.updateMinMax(); + }, + currentDocUpdate(currentDoc) { + this.currentDoc = currentDoc; + }, + async add() { + if (await this.validate(true, false)) { + const item = this.newInstance(); + item._id = cuid(); + this.next.push(item); + this.select(item._id); + this.updateMinMax(); + } + }, + updateMinMax() { + let minError = false; + let maxError = false; + if (this.effectiveMin) { + if (this.next.length < this.effectiveMin) { + minError = true; + } + } + if (this.field.max !== undefined) { + if (this.next.length > this.field.max) { + maxError = true; + } + } + this.minError = minError; + this.maxError = maxError; + }, + async submit() { + if (await this.validate(true, true)) { + this.currentDocToCurrentItem(); + for (const item of this.next) { + item.metaType = 'arrayItem'; + item.scopedArrayName = this.field.scopedArrayName; + } + this.$emit('modal-result', this.next); + this.modal.showModal = false; + } + }, + currentDocToCurrentItem() { + if (!this.currentId) { + return; + } + const currentIndex = this.next.findIndex(item => item._id === this.currentId); + this.next[currentIndex] = this.currentDoc.data; + }, + getFieldValue(name) { + return this.currentDoc.data[name]; + }, + isModified() { + if (this.currentId) { + const currentIndex = this.next.findIndex(item => item._id === this.currentId); + if (detectDocChange(this.schema, this.next[currentIndex], this.currentDoc.data)) { + return true; + } + } + if (this.next.length !== this.original.length) { + return true; + } + for (let i = 0; (i < this.next.length); i++) { + if (this.next[i]._id !== this.original[i]._id) { + return true; + } + if (detectDocChange(this.schema, this.next[i], this.original[i])) { + return true; + } + } + return false; + }, + async validate(validateItem, validateLength) { + if (validateItem) { + this.triggerValidation = true; + } + await this.nextTick(); + if (validateLength) { + this.updateMinMax(); + } + if ( + (validateLength && (this.minError || this.maxError)) || + (validateItem && (this.currentDoc && this.currentDoc.hasErrors)) + ) { + await apos.notify('apostrophe:resolveErrorsFirst', { + type: 'warning', + icon: 'alert-circle-icon', + dismiss: true + }); + return false; + } else { + return true; + } + }, + // Awaitable nextTick + nextTick() { + return new Promise((resolve, reject) => { + this.$nextTick(() => { + return resolve(); + }); + }); + }, + newInstance() { + const instance = {}; + for (const field of this.schema) { + if (field.def !== undefined) { + instance[field.name] = klona(field.def); + } + } + return instance; + }, + label(item) { + let candidate; + if (this.field.titleField) { + + // Initial field value + candidate = get(item, this.field.titleField); + + // If the titleField references a select input, use the + // select label as the slat label, rather than the value. + if (this.titleFieldChoices) { + const choice = this.titleFieldChoices.find(choice => choice.value === candidate); + if (choice && choice.label) { + candidate = choice.label; + } + } + + } else if (this.schema.find(field => field.name === 'title') && (item.title !== undefined)) { + candidate = item.title; + } + if ((candidate == null) || candidate === '') { + for (let i = 0; (i < this.next.length); i++) { + if (this.next[i]._id === item._id) { + candidate = `#${i + 1}`; + break; + } + } + } + return candidate; + }, + withLabels(items) { + const result = items.map(item => ({ + ...item, + title: this.label(item) + })); + return result; + }, + async getTitleFieldChoices() { + // If the titleField references a select input, get it's choices + // to use as labels for the slat UI + + let choices = null; + const titleField = this.schema.find(field => field.name === this.field.titleField); + + // The titleField is a select + if (titleField?.choices) { + + // Choices are provided by a method + if (typeof titleField.choices === 'string') { + const action = `${this.moduleOptions.action}/choices`; + try { + const result = await apos.http.get( + action, + { + qs: { + fieldId: titleField._id + } + } + ); + if (result && result.choices) { + choices = result.choices; + } + } catch (e) { + console.error(this.$t('apostrophe:errorFetchingTitleFieldChoicesByMethod', { name: titleField.name })); + } + + // Choices are a normal, hardcoded array + } else if (Array.isArray(titleField.choices)) { + choices = titleField.choices; + } + } + return choices; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputArea.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputArea.js new file mode 100644 index 0000000000..a79b204412 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputArea.js @@ -0,0 +1,89 @@ + +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import cuid from 'cuid'; + +export default { + name: 'AposInputArea', + mixins: [ AposInputMixin ], + props: { + generation: { + type: Number, + required: false, + default() { + return null; + } + } + }, + data () { + return { + next: this.value.data || this.getEmptyValue(), + error: false, + // This is just meant to be sufficient to prevent unintended collisions + // in the UI between id attributes + uid: Math.random() + }; + }, + computed: { + editorComponent() { + return window.apos.area.components.editor; + }, + choices() { + const result = []; + + let widgets = this.field.options.widgets || {}; + if (this.field.options.groups) { + for (const group of Object.entries(this.field.options.groups)) { + widgets = { + ...widgets, + ...group.widgets + }; + } + } + + for (const [ name, options ] of Object.entries(widgets)) { + result.push({ + name, + label: options.addLabel || apos.modules[`${name}-widget`].label + }); + } + return result; + } + }, + methods: { + getEmptyValue() { + return { + metaType: 'area', + _id: cuid(), + items: [] + }; + }, + watchValue () { + this.error = this.value.error; + this.next = this.value.data || this.getEmptyValue(); + }, + validate(value) { + if (this.field.required) { + if (!value.items.length) { + return 'required'; + } + } + if (this.field.min) { + if (value.items.length && (value.items.length < this.field.min)) { + return 'min'; + } + } + if (this.field.max) { + if (value.items.length && (value.items.length > this.field.max)) { + return 'max'; + } + } + return false; + }, + changed($event) { + this.next = { + ...this.next, + items: $event.items + }; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputArray.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputArray.js new file mode 100644 index 0000000000..6ab4109140 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputArray.js @@ -0,0 +1,257 @@ +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin.js'; +import AposInputFollowingMixin from 'Modules/@apostrophecms/schema/mixins/AposInputFollowingMixin.js'; +import AposInputConditionalFieldsMixin from 'Modules/@apostrophecms/schema/mixins/AposInputConditionalFieldsMixin.js'; + +import cuid from 'cuid'; +import { klona } from 'klona'; +import { get } from 'lodash'; +import draggable from 'vuedraggable'; + +export default { + name: 'AposInputArray', + components: { draggable }, + mixins: [ + AposInputMixin, + AposInputFollowingMixin, + AposInputConditionalFieldsMixin + ], + props: { + generation: { + type: Number, + required: false, + default: null + } + }, + data() { + const next = this.getNext(); + const data = { + next, + items: modelItems(next, this.field) + }; + return data; + }, + computed: { + // required by the conditional fields mixin + schema() { + return this.field.schema; + }, + alwaysExpand() { + return alwaysExpand(this.field); + }, + listId() { + return `sortableList-${cuid()}`; + }, + dragOptions() { + return { + disabled: !this.field.draggable || this.field.readOnly || this.next.length <= 1, + ghostClass: 'apos-is-dragging', + handle: '.apos-drag-handle' + }; + }, + itemLabel() { + return this.field.itemLabel + ? { + key: 'apostrophe:addType', + type: this.$t(this.field.itemLabel) + } + : 'apostrophe:addItem'; + }, + editLabel() { + return { + key: 'apostrophe:editType', + type: this.$t(this.field.label) + }; + }, + effectiveError() { + const error = this.error || this.serverError; + // Server-side errors behave differently + const name = error?.name || error; + if (name === 'invalid') { + // Always due to a subproperty which will display its own error, + // don't confuse the user + return false; + } + return error; + } + }, + watch: { + generation() { + this.next = this.getNext(); + this.items = modelItems(this.next, this.field); + }, + items: { + deep: true, + handler() { + const erroneous = this.items.filter(item => item.schemaInput.hasErrors); + if (erroneous.length) { + erroneous.forEach(item => { + if (!item.open) { + // Make errors visible + item.open = true; + } + }); + } else { + const next = this.items.map(item => ({ + ...item.schemaInput.data, + _id: item._id, + metaType: 'arrayItem', + scopedArrayName: this.field.scopedArrayName + })); + this.next = next; + } + // Our validate method was called first before that of + // the subfields, so remedy that by calling again on any + // change to the subfield state during validation + if (this.triggerValidation) { + this.validateAndEmit(); + } + } + } + }, + async created() { + if (this.field.inline) { + await this.evaluateExternalConditions(); + } + }, + methods: { + validate(value) { + if (this.items.find(item => item.schemaInput.hasErrors)) { + return 'invalid'; + } + if (this.field.required && !value.length) { + return 'required'; + } + if (this.field.min && value.length < this.field.min) { + return 'min'; + } + if (this.field.max && value.length > this.field.max) { + return 'max'; + } + if (value.length && this.field.fields && this.field.fields.add) { + const [ uniqueFieldName, uniqueFieldSchema ] = Object.entries(this.field.fields.add).find(([ , subfield ]) => subfield.unique) || []; + if (uniqueFieldName) { + const duplicates = this.next + .map(item => + Array.isArray(item[uniqueFieldName]) + ? item[uniqueFieldName].map(i => i._id).sort().join('|') + : item[uniqueFieldName]) + .filter((item, index, array) => array.indexOf(item) !== index); + + if (duplicates.length) { + duplicates.forEach(duplicate => { + this.items.forEach(item => { + uniqueFieldSchema.type === 'relationship' + ? item.schemaInput.data[uniqueFieldName] && item.schemaInput.data[uniqueFieldName].forEach(datum => { + item.schemaInput.fieldState[uniqueFieldName].duplicate = duplicate.split('|').find(i => i === datum._id); + }) + : item.schemaInput.fieldState[uniqueFieldName].duplicate = item.schemaInput.data[uniqueFieldName] === duplicate; + }); + }); + + return { + name: 'duplicate', + message: `${this.$t('apostrophe:duplicateError')} ${this.$t(uniqueFieldSchema.label) || uniqueFieldName}` + }; + } + } + } + return false; + }, + async edit() { + const result = await apos.modal.execute('AposArrayEditor', { + field: this.field, + items: this.next, + serverError: this.serverError, + docId: this.docId, + parentFollowingValues: this.followingValues + }); + if (result) { + this.next = result; + } + }, + getNext() { + // Next should consistently be an array. + return (this.value && Array.isArray(this.value.data)) + ? this.value.data : (this.field.def || []); + }, + disableAdd() { + return this.field.max && (this.items.length >= this.field.max); + }, + remove(_id) { + this.items = this.items.filter(item => item._id !== _id); + }, + add() { + const _id = cuid(); + this.items.push({ + _id, + schemaInput: { + data: this.newInstance() + }, + open: alwaysExpand(this.field) + }); + this.openInlineItem(_id); + }, + newInstance() { + const instance = {}; + for (const field of this.field.schema) { + if (field.def !== undefined) { + instance[field.name] = klona(field.def); + } + } + return instance; + }, + getLabel(id, index) { + const titleField = this.field.titleField || null; + const item = this.items.find(item => item._id === id); + return get(item.schemaInput.data, titleField) || `Item ${index + 1}`; + }, + openInlineItem(id) { + this.items.forEach(item => { + item.open = (item._id === id) || this.alwaysExpand; + }); + }, + closeInlineItem(id) { + this.items.forEach(item => { + item.open = this.alwaysExpand; + }); + }, + getFollowingValues(item) { + return this.computeFollowingValues(item.schemaInput.data); + }, + // Retrieve table heading fields from the schema, based on the currently + // opened item. Available only when the field style is `table`. + visibleSchema() { + if (this.field.style !== 'table') { + return this.schema; + } + const currentItem = this.items.find(item => item.open) || this.items[this.items.length - 1]; + const conditions = this.conditionalFields(currentItem?.schemaInput?.data || {}); + return this.schema.filter( + field => conditions[field.name] !== false + ); + } + } +}; + +function modelItems(items, field) { + return items.map(item => { + const open = alwaysExpand(field); + return { + _id: item._id || cuid(), + schemaInput: { + data: item + }, + open + }; + }); +} + +function alwaysExpand(field) { + if (!field.inline) { + return false; + } + if (field.inline.alwaysExpand === undefined) { + return field.schema.length < 3; + } + return field.inline.alwaysExpand; +} diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputAttachment.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputAttachment.js new file mode 100644 index 0000000000..54d27180fc --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputAttachment.js @@ -0,0 +1,81 @@ + +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin.js'; + +export default { + name: 'AposInputAttachment', + mixins: [ AposInputMixin ], + emits: [ 'upload-started', 'upload-complete' ], + data () { + return { + // Next should consistently be an object or null (an attachment field with + // no value yet is null, per server side). + next: (this.value && (typeof this.value.data === 'object')) + ? this.value.data : (this.field.def || null), + disabled: false, + uploading: false + }; + }, + async mounted () { + this.disabled = this.field.readOnly; + }, + methods: { + updated (items) { + // NOTE: This is limited to a single item. + this.next = items.length > 0 ? items[0] : null; + }, + validate (value) { + if (this.field.required && !value) { + return 'required'; + } + + return false; + }, + async uploadMedia (file) { + if (!this.disabled || !this.limitReached) { + try { + this.disabled = true; + this.uploading = true; + + await apos.notify('apostrophe:uploading', { + dismiss: true, + icon: 'cloud-upload-icon', + interpolate: { + name: file.name + } + }); + + const formData = new window.FormData(); + formData.append('file', file); + const attachment = await apos.http.post('/api/v1/@apostrophecms/attachment/upload', { + body: formData + }); + + await apos.notify('apostrophe:uploaded', { + type: 'success', + dismiss: true, + icon: 'check-all-icon', + interpolate: { + name: file.name, + count: 1 + } + }); + + this.$emit('upload-complete'); + this.value.data = attachment; + } catch (error) { + console.error('Error uploading file.', error); + const msg = error.body && error.body.message ? error.body.message : this.$t('apostrophe:uploadError'); + await apos.notify(msg, { + type: 'danger', + icon: 'alert-circle-icon', + dismiss: true, + localize: false + }); + } finally { + this.disabled = false; + this.uploading = false; + } + } + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputBoolean.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputBoolean.js new file mode 100644 index 0000000000..a977f4674d --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputBoolean.js @@ -0,0 +1,48 @@ + +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; + +export default { + name: 'AposInputBoolean', + mixins: [ AposInputMixin ], + computed: { + classList: function () { + return [ + 'apos-input-wrapper', + 'apos-boolean', + { + 'apos-boolean--toggle': this.field.toggle + } + ]; + }, + trueLabel: function () { + if (this.field.toggle && this.field.toggle.true && + typeof this.field.toggle.true === 'string') { + return this.field.toggle.true; + } else { + return false; + } + }, + falseLabel: function () { + if (this.field.toggle && this.field.toggle && + typeof this.field.toggle.false === 'string') { + return this.field.toggle.false; + } else { + return false; + } + } + }, + methods: { + setValue(val) { + this.next = val; + this.$refs[(!val).toString()].checked = false; + }, + validate(value) { + if (this.field.required) { + if (!value && value !== false) { + return 'required'; + } + } + return false; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js new file mode 100644 index 0000000000..e375fdd9e1 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js @@ -0,0 +1,68 @@ + +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import AposInputChoicesMixin from 'Modules/@apostrophecms/schema/mixins/AposInputChoicesMixin'; + +export default { + name: 'AposInputCheckboxes', + mixins: [ AposInputMixin, AposInputChoicesMixin ], + beforeMount () { + this.value.data = Array.isArray(this.value.data) ? this.value.data : []; + }, + methods: { + getChoiceId(uid, value) { + return uid + value.replace(/\s/g, ''); + }, + watchValue () { + this.error = this.value.error; + this.next = this.value.data || []; + }, + validate(values) { + // The choices and values should always be arrays. + if (!Array.isArray(this.choices) || !Array.isArray(values)) { + return 'malformed'; + } + + if (this.field.required && !values.length) { + return 'required'; + } + + if (this.field.min) { + if ((values != null) && (values.length < this.field.min)) { + return this.$t('apostrophe:minUi', { number: this.field.min }); + } + } + if (this.field.max) { + if ((values != null) && (values.length > this.field.max)) { + return this.$t('apostrophe:maxUi', { number: this.field.max }); + } + } + + if (Array.isArray(values)) { + values.forEach(chosen => { + if (!this.choices.map(choice => { + return choice.value; + }).includes(chosen)) { + return 'invalid'; + } + }); + } + + return false; + }, + selectItems(choice) { + if (choice.value === '__all') { + this.value.data = this.choices.length === this.value.data.length + ? [] + : this.choices.map(({ value }) => value); + + return; + } + + if (this.value.data.includes(choice.value)) { + this.value.data = this.value.data.filter((val) => val !== choice.value); + } else { + this.value.data.push(choice.value); + } + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js new file mode 100644 index 0000000000..638b4cd223 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js @@ -0,0 +1,98 @@ + +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import Picker from '@apostrophecms/vue-color/src/components/Sketch'; +import tinycolor from 'tinycolor2'; + +export default { + name: 'AposInputColor', + components: { + Picker + }, + mixins: [ AposInputMixin ], + data() { + return { + active: false, + tinyColorObj: null, + startsNull: false, + defaultFormat: 'hex8', + defaultPickerOptions: { + presetColors: [ + '#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321', + '#417505', '#BD10E0', '#9013FE', '#4A90E2', '#50E3C2', + '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF' + ], + disableAlpha: false, + disableFields: false + } + }; + }, + computed: { + buttonOptions() { + return { + label: this.field.label, + type: 'color', + color: this.value.data || '' + }; + }, + format() { + return this.field.options && this.field.options.format + ? this.field.options.format + : this.defaultFormat; + }, + pickerOptions() { + let fieldOptions = {}; + if (this.field.options && this.field.options.pickerOptions) { + fieldOptions = this.field.options.pickerOptions; + } + return Object.assign(this.defaultPickerOptions, fieldOptions); + }, + + valueLabel() { + if (this.next) { + return this.next; + } else { + return 'None Selected'; + } + }, + classList() { + return [ + 'apos-input-wrapper', + 'apos-color' + ]; + } + }, + mounted() { + if (!this.next) { + this.next = null; + } + }, + methods: { + open() { + this.active = true; + }, + close() { + this.active = false; + }, + update(value) { + this.tinyColorObj = tinycolor(value.hsl); + this.next = this.tinyColorObj.toString(this.format); + }, + validate(value) { + if (this.field.required) { + if (!value) { + return 'required'; + } + } + + if (!value) { + return false; + } + + const color = tinycolor(value); + return color.isValid() ? false : 'Error'; + }, + clear() { + this.next = null; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputDateAndTime.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputDateAndTime.js new file mode 100644 index 0000000000..ce6bd1fe4e --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputDateAndTime.js @@ -0,0 +1,49 @@ +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import dayjs from 'dayjs'; + +export default { + mixins: [ AposInputMixin ], + emits: [ 'return' ], + data() { + return { + next: (this.value && this.value.data) || null, + date: '', + time: '', + disabled: !this.field.required + }; + }, + mounted () { + this.initDateAndTime(); + }, + methods: { + toggle() { + this.disabled = !this.disabled; + + if (this.disabled) { + this.next = null; + } + }, + validate() { + if (this.field.required && !this.next) { + return 'required'; + } + }, + initDateAndTime() { + if (this.next) { + this.date = dayjs(this.next).format('YYYY-MM-DD'); + this.time = dayjs(this.next).format('HH:mm:ss'); + this.disabled = false; + } + }, + setDateAndTime() { + if (this.date) { + this.next = dayjs(`${this.date} ${this.time}`.trim()).toISOString(); + this.disabled = false; + } else { + this.next = null; + this.disabled = true; + } + } + } + +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputObject.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputObject.js new file mode 100644 index 0000000000..fdb3870843 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputObject.js @@ -0,0 +1,86 @@ + +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin.js'; +import AposInputFollowingMixin from 'Modules/@apostrophecms/schema/mixins/AposInputFollowingMixin.js'; +import AposInputConditionalFieldsMixin from 'Modules/@apostrophecms/schema/mixins/AposInputConditionalFieldsMixin.js'; + +export default { + name: 'AposInputObject', + mixins: [ + AposInputMixin, + AposInputFollowingMixin, + AposInputConditionalFieldsMixin + ], + props: { + generation: { + type: Number, + required: false, + default() { + return null; + } + }, + docId: { + type: String, + required: false, + default() { + return null; + } + } + }, + data () { + const next = this.getNext(); + return { + schemaInput: { + data: next + }, + next + }; + }, + computed: { + followingValuesWithParent() { + return this.computeFollowingValues(this.schemaInput.data); + }, + // Reqiured for AposInputConditionalFieldsMixin + schema() { + return this.field.schema; + }, + values() { + return this.schemaInput.data; + } + }, + watch: { + schemaInput: { + deep: true, + handler() { + if (!this.schemaInput.hasErrors) { + this.next = this.schemaInput.data; + } + // Our validate method was called first before that of + // the subfields, so remedy that by calling again on any + // change to the subfield state during validation + if (this.triggerValidation) { + this.validateAndEmit(); + } + } + }, + generation() { + this.next = this.getNext(); + this.schemaInput = { + data: this.next + }; + } + }, + async created() { + await this.evaluateExternalConditions(); + }, + methods: { + validate (value) { + if (this.schemaInput.hasErrors) { + return 'invalid'; + } + }, + // Return next at mount or when generation changes + getNext() { + return this.value ? this.value.data : (this.field.def || {}); + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputPassword.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputPassword.js new file mode 100644 index 0000000000..415462d609 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputPassword.js @@ -0,0 +1,41 @@ + +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; + +export default { + name: 'AposInputPassword', + mixins: [ AposInputMixin ], + emits: [ 'return' ], + computed: { + tabindex () { + return this.field.disableFocus ? '-1' : '0'; + } + }, + methods: { + validate(value) { + if (this.field.required) { + if (!value.length) { + return { message: 'required' }; + } + } + if (this.field.min) { + if (value.length && (value.length < this.field.min)) { + return { + message: this.$t('apostrophe:passwordErrorMin', { + min: this.field.min + }) + }; + } + } + if (this.field.max) { + if (value.length && (value.length > this.field.max)) { + return { + message: this.$t('apostrophe:passwordErrorMax', { + max: this.field.max + }) + }; + } + } + return false; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js new file mode 100644 index 0000000000..41df9d2f4f --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js @@ -0,0 +1,29 @@ +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import AposInputChoicesMixin from 'Modules/@apostrophecms/schema/mixins/AposInputChoicesMixin'; +import InformationIcon from 'vue-material-design-icons/Information.vue'; + +export default { + name: 'AposInputRadio', + components: { InformationIcon }, + mixins: [ AposInputMixin, AposInputChoicesMixin ], + methods: { + getChoiceId(uid, value) { + return (uid + JSON.stringify(value)).replace(/\s+/g, ''); + }, + validate(value) { + if (this.field.required && (value === '')) { + return 'required'; + } + + if (value && !this.choices.find(choice => choice.value === value)) { + return 'invalid'; + } + + return false; + }, + change(value) { + // Allows expression of non-string values + this.next = this.choices.find(choice => choice.value === JSON.parse(value)).value; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputRange.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputRange.js new file mode 100644 index 0000000000..384fea1ba5 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputRange.js @@ -0,0 +1,60 @@ +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; + +export default { + name: 'AposInputRange', + mixins: [ AposInputMixin ], + data() { + return { + unit: this.field.unit || '' + }; + }, + computed: { + minLabel() { + return this.field.min + this.unit; + }, + maxLabel() { + return this.field.max + this.unit; + }, + valueLabel() { + return this.next + this.unit; + }, + isSet() { + // Detect whether or not a range is currently unset + // Use this flag to hide/show UI elements + if (this.next >= this.field.min) { + return true; + } else { + return false; + } + } + }, + mounted() { + // The range spec defaults to a value of midway between the min and max + // Example: a range with an unset value and a min of 0 and max of 100 will become 50 + // This does not allow ranges to go unset :( + if (!this.next) { + this.unset(); + } + }, + methods: { + // Default to a value outside the range when no def is defined, + // to be used as a flag. + // The value will be set to null later in validation + unset() { + this.next = typeof this.field.def === 'number' + ? this.field.def + : this.field.min - 1; + }, + validate(value) { + if (this.field.required) { + if (!value) { + return 'required'; + } + } + return false; + }, + convert(value) { + return parseFloat(value); + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js new file mode 100644 index 0000000000..cb2295db56 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js @@ -0,0 +1,262 @@ +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import { klona } from 'klona'; + +export default { + name: 'AposInputRelationship', + mixins: [ AposInputMixin ], + emits: [ 'input' ], + data () { + const next = (this.value && Array.isArray(this.value.data)) + ? klona(this.value.data) : (klona(this.field.def) || []); + + // Remember relationship subfield values even if a document + // is temporarily deselected, easing the user's pain if they + // inadvertently deselect something for a moment + const subfields = Object.fromEntries( + (next || []).filter(doc => doc._fields) + .map(doc => [ doc._id, doc._fields ]) + ); + + return { + searchTerm: '', + searchList: [], + next, + subfields, + disabled: false, + searching: false, + choosing: false, + relationshipSchema: null + }; + }, + computed: { + limitReached() { + return this.field.max === this.next.length; + }, + pluralLabel() { + return apos.modules[this.field.withType].pluralLabel; + }, + // TODO get 'Search' server for better i18n + placeholder() { + return this.field.placeholder || { + key: 'apostrophe:searchDocType', + type: this.$t(this.pluralLabel) + }; + }, + // TODO get 'Browse' for better i18n + browseLabel() { + return { + key: 'apostrophe:browseDocType', + type: this.$t(this.pluralLabel) + }; + }, + suggestion() { + return { + disabled: true, + tooltip: false, + icon: false, + classes: [ 'suggestion' ], + title: this.$t(this.field.suggestionLabel), + help: this.$t({ + key: this.field.suggestionHelp || 'apostrophe:relationshipSuggestionHelp', + type: this.$t(this.pluralLabel) + }), + customFields: [ 'help' ] + }; + }, + hint() { + return { + disabled: true, + tooltip: false, + icon: 'binoculars-icon', + iconSize: 35, + classes: [ 'hint' ], + title: this.$t('apostrophe:relationshipSuggestionNoResults'), + help: this.$t({ + key: this.field.browse + ? 'apostrophe:relationshipSuggestionSearchAndBrowse' + : 'apostrophe:relationshipSuggestionSearch', + type: this.$t(this.pluralLabel) + }), + customFields: [ 'help' ] + }; + }, + chooserComponent () { + return apos.modules[this.field.withType].components.managerModal; + }, + disableUnpublished() { + return apos.modules[this.field.withType].localized; + }, + buttonModifiers() { + const modifiers = [ 'small' ]; + if (this.modifiers.includes('no-search')) { + modifiers.push('block'); + } + return modifiers; + }, + minSize() { + const [ widgetOptions = {} ] = apos.area.widgetOptions; + + return widgetOptions.minSize || []; + }, + duplicate () { + return this.value.duplicate ? 'apos-input--error' : null; + } + }, + watch: { + next(after, before) { + for (const doc of before) { + this.subfields[doc._id] = doc._fields; + } + for (const doc of after) { + if (Object.keys(doc._fields || {}).length) { + continue; + } + doc._fields = this.field.schema && (this.subfields[doc._id] + ? this.subfields[doc._id] + : this.getDefault()); + } + } + }, + mounted () { + this.checkLimit(); + }, + methods: { + validate(value) { + this.checkLimit(); + + if (this.field.required && !value.length) { + return { message: 'required' }; + } + + if (this.field.min && this.field.min > value.length) { + return { message: `minimum of ${this.field.min} required` }; + } + + return false; + }, + checkLimit() { + if (this.limitReached) { + this.searchTerm = 'Limit reached!'; + } else if (this.searchTerm === 'Limit reached!') { + this.searchTerm = ''; + } + + this.disabled = !!this.limitReached; + }, + updateSelected(items) { + this.next = items; + }, + async search(qs) { + if (this.field.suggestionLimit) { + qs.perPage = this.field.suggestionLimit; + } + if (this.field.suggestionSort) { + qs.sort = this.field.suggestionSort; + } + if (this.field.withType === '@apostrophecms/image') { + apos.bus.$emit('piece-relationship-query', qs); + } + + this.searching = true; + const list = await apos.http.get( + apos.modules[this.field.withType].action, + { + busy: false, + draft: true, + qs + } + ); + + const removeSelectedItem = item => !this.next.map(i => i._id).includes(item._id); + const formatItems = item => ({ + ...item, + disabled: this.disableUnpublished && !item.lastPublishedAt + }); + + const results = (list.results || []) + .filter(removeSelectedItem) + .map(formatItems); + + const suggestion = !qs.autocomplete && this.suggestion; + const hint = (!qs.autocomplete || !results.length) && this.hint; + + this.searchList = [ suggestion, ...results, hint ].filter(Boolean); + this.searching = false; + }, + async input () { + if (this.searching) { + return; + } + + const trimmed = this.searchTerm.trim(); + const qs = trimmed.length + ? { + autocomplete: trimmed + } + : {}; + + await this.search(qs); + }, + handleFocusOut() { + // hide search list when click outside the input + // timeout to execute "@select" method before + setTimeout(() => { + this.searchList = []; + }, 200); + }, + watchValue () { + this.error = this.value.error; + // Ensure the internal state is an array. + this.next = Array.isArray(this.value.data) ? this.value.data : []; + }, + async choose () { + const result = await apos.modal.execute(this.chooserComponent, { + title: this.field.label || this.field.name, + moduleName: this.field.withType, + chosen: this.next, + relationshipField: this.field + }); + if (result) { + this.updateSelected(result); + } + }, + async editRelationship (item) { + const editor = this.field.editor || 'AposRelationshipEditor'; + + const result = await apos.modal.execute(editor, { + schema: this.field.schema, + item, + title: item.title, + value: item._fields + }); + + if (result) { + const index = this.next.findIndex(_item => _item._id === item._id); + this.$set(this.next, index, { + ...this.next[index], + _fields: result + }); + } + }, + getEditRelationshipLabel () { + if (this.field.editor === 'AposImageRelationshipEditor') { + return 'apostrophe:editImageAdjustments'; + } + }, + getDefault() { + const object = {}; + this.field.schema.forEach(field => { + if (field.name.startsWith('_')) { + return; + } + // Using `hasOwn` here, not simply checking if `field.def` is truthy + // so that `false`, `null`, `''` or `0` are taken into account: + const hasDefaultValue = Object.hasOwn(field, 'def'); + object[field.name] = hasDefaultValue + ? klona(field.def) + : null; + }); + return object; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputSelect.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputSelect.js new file mode 100644 index 0000000000..d42a285496 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputSelect.js @@ -0,0 +1,41 @@ +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import AposInputChoicesMixin from 'Modules/@apostrophecms/schema/mixins/AposInputChoicesMixin'; + +export default { + name: 'AposInputSelect', + mixins: [ AposInputMixin, AposInputChoicesMixin ], + props: { + icon: { + type: String, + default: 'menu-down-icon' + } + }, + data() { + return { + next: (this.value.data == null) ? null : this.value.data, + choices: [] + }; + }, + computed: { + classes () { + return [ this.value.duplicate && 'apos-input--error' ]; + } + }, + methods: { + validate(value) { + if (this.field.required && (value === null)) { + return 'required'; + } + + if (value && !this.choices.find(choice => choice.value === value)) { + return 'invalid'; + } + + return false; + }, + change(value) { + // Allows expression of non-string values + this.next = this.choices.find(choice => choice.value === value).value; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js new file mode 100644 index 0000000000..3f8d7dcfb6 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js @@ -0,0 +1,278 @@ +// NOTE: This is a temporary component, copying AposInputString. Base modules +// already have `type: 'slug'` fields, so this is needed to avoid distracting +// errors. +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; +import sluggo from 'sluggo'; +import debounce from 'debounce-async'; +import { klona } from 'klona'; + +export default { + name: 'AposInputSlug', + mixins: [ AposInputMixin ], + emits: [ 'return' ], + data() { + return { + conflict: false, + isArchived: null, + originalSlugPartsLength: null + }; + }, + computed: { + tabindex () { + return this.field.disableFocus ? '-1' : '0'; + }, + type () { + if (this.field.type) { + return this.field.type; + } else { + return 'text'; + } + }, + classes () { + return [ 'apos-input', 'apos-input--text', 'apos-input--slug' ]; + }, + wrapperClasses () { + return [ 'apos-input-wrapper' ].concat(this.localePrefix ? [ 'apos-input-wrapper--with-prefix' ] : []); + }, + icon () { + if (this.error) { + return 'circle-medium-icon'; + } else if (this.field.icon) { + return this.field.icon; + } else { + return null; + } + }, + prefix () { + return this.field.prefix || ''; + }, + localePrefix() { + return this.field.page && apos.i18n.locales[apos.i18n.locale].prefix; + } + }, + watch: { + followingValues: { + // We are usually interested in followingValue.title, but a + // secondary slug field could be configured to watch + // one or more other fields + deep: true, + handler(newValue, oldValue) { + const newClone = klona(newValue); + const oldClone = klona(oldValue); + + // Track whether the slug is archived for prefixing. + this.isArchived = newValue.archived; + // We only want the string properties to build the slug itself. + delete newClone.archived; + delete oldClone.archived; + + oldValue = Object.values(oldClone).join(' '); + newValue = Object.values(newClone).join(' '); + + if (this.compatible(oldValue, this.next) && !newValue.archived) { + // If this is a page slug, we only replace the last section of the slug. + if (this.field.page) { + let parts = this.next.split('/'); + parts = parts.filter(part => part.length > 0); + if ((!this.originalSlugPartsLength && parts.length) || (this.originalSlugPartsLength && parts.length === (this.originalSlugPartsLength - 1))) { + // Remove last path component so we can replace it + parts.pop(); + } + parts.push(this.slugify(newValue, { componentOnly: true })); + if (parts[0].length) { + // TODO: handle page archives. + this.next = `/${parts.join('/')}`; + } + } else { + this.next = this.slugify(newValue); + } + } + } + } + }, + async mounted() { + this.debouncedCheckConflict = debounce(() => this.checkConflict(), 250); + if (this.next.length) { + await this.debouncedCheckConflict(); + } + this.originalSlugPartsLength = this.next.split('/').length; + }, + methods: { + async watchNext() { + this.next = this.slugify(this.next); + this.validateAndEmit(); + try { + await this.debouncedCheckConflict(); + } catch (e) { + if (e === 'canceled') { + // That's fine + } else { + throw e; + } + } + }, + validate(value) { + if (this.conflict) { + return { + name: 'conflict', + message: 'apostrophe:slugInUse' + }; + } + if (this.field.required) { + if (!value.length) { + return 'required'; + } + } + if (this.field.min) { + if (value.length && (value.length < this.field.min)) { + return 'min'; + } + } + if (this.field.max) { + if (value.length && (value.length > this.field.max)) { + return 'max'; + } + } + return false; + }, + compatible(title, slug) { + if ((typeof title) !== 'string') { + title = ''; + } + if (this.field.page) { + const matches = slug.match(/[^/]+$/); + slug = (matches && matches[0]) || ''; + } + return ((title === '') && (slug === `${this.prefix}`)) || + this.slugify(title) === this.slugify(slug); + }, + // if componentOnly is true, we are slugifying just one component of + // a slug as part of following the title field, and so we do *not* + // want to allow slashes (when editing a page) or set a prefix. + slugify(s, { componentOnly = false } = {}) { + const options = { + def: '' + }; + + if (this.field.page && !componentOnly) { + options.allow = '/'; + } + + let preserveDash = false; + // When you are typing a slug it feels wrong for hyphens you typed + // to disappear as you go, so if the last character is not valid in a slug, + // restore it after we call sluggo for the full string + if (this.focus && s.length && (sluggo(s.charAt(s.length - 1), options) === '')) { + preserveDash = true; + } + + s = sluggo(s, options); + if (preserveDash) { + s += '-'; + } + + if (this.field.page && !componentOnly) { + if (!this.followingValues?.title) { + const nextParts = this.next.split('/'); + if (s === nextParts[nextParts.length - 1]) { + s = ''; + if (this.originalSlugPartsLength === nextParts.length) { + nextParts.pop(); + } + this.next = nextParts.join('/'); + } + } + if (!s.charAt(0) !== '/') { + s = `/${s}`; + } + s = s.replace(/\/+/g, '/'); + if (s !== '/') { + s = s.replace(/\/$/, ''); + } + if (!this.followingValues?.title && s.length) { + s += '/'; + } + } + + if (!componentOnly) { + s = this.setPrefix(s); + } + + return s; + }, + setPrefix (slug) { + // Get a fresh clone of the slug. + let updated = slug; + const archivedRegexp = new RegExp(`^deduplicate-[a-z0-9]+-${this.prefix}`); + + // Prefix if the slug doesn't start with the prefix OR if its archived + // and it doesn't start with the dedupe+prefix pattern. + if ( + !updated.startsWith(this.prefix) || + (this.isArchived && !updated.match(archivedRegexp)) + ) { + let archivePrefix = ''; + // If archived, remove the dedupe pattern to add again later. + if (this.isArchived) { + archivePrefix = updated.match(/^deduplicate-[a-z0-9]+-/); + updated = updated.replace(archivePrefix, ''); + } + + if (this.prefix.startsWith(updated)) { + // If they delete the `-`, and the prefix is `recipe-`, + // we want to restore `recipe-`, not set it to `recipe-recipe` + updated = this.prefix; + } else { + // Make sure we're not double prefixing archived slugs. + updated = updated.startsWith(this.prefix) ? updated : this.prefix + updated; + } + // Reapply the dedupe pattern if archived. If being restored from the + // doc editor modal it will momentarily be tracked as archived but + // without not have the archive prefix, so check that too. + updated = this.isArchived && archivePrefix ? `${archivePrefix}${updated}` : updated; + } + + return updated; + }, + async checkConflict() { + let slug; + try { + slug = this.next; + if (slug.length) { + await apos.http.post(`${apos.doc.action}/slug-taken`, { + body: { + slug, + _id: this.docId + }, + draft: true + }); + // Still relevant? + if (slug === this.next) { + this.conflict = false; + this.validateAndEmit(); + } else { + // Can ignore it, another request + // probably already in-flight + } + } + } catch (e) { + // 409: Conflict (slug in use) + if (e.status === 409) { + // Still relevant? + if (slug === this.next) { + this.conflict = true; + this.validateAndEmit(); + } else { + // Can ignore it, another request + // probably already in-flight + } + } else { + throw e; + } + } + }, + passFocus() { + this.$refs.input.focus(); + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputString.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputString.js new file mode 100644 index 0000000000..d056ec468b --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputString.js @@ -0,0 +1,170 @@ +import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin'; + +export default { + name: 'AposInputString', + mixins: [ AposInputMixin ], + emits: [ 'return' ], + data () { + return { + step: undefined, + wasPopulated: false + }; + }, + computed: { + tabindex () { + return this.field.disableFocus ? '-1' : '0'; + }, + type () { + if (this.field.type) { + if (this.field.type === 'float' || this.field.type === 'integer') { + return 'number'; + } + if (this.field.type === 'string' || this.field.type === 'slug') { + return 'text'; + } + return this.field.type; + } else { + return 'text'; + } + }, + classes () { + return [ 'apos-input', `apos-input--${this.type}`, this.value.duplicate && 'apos-input--error' ]; + }, + icon () { + if (this.error) { + return 'circle-medium-icon'; + } else if (this.field.icon) { + return this.field.icon; + } else { + return null; + } + } + }, + watch: { + followingValues: { + // We may be following multiple fields, like firstName and lastName, + // or none at all, depending + deep: true, + handler(newValue, oldValue) { + // Follow the value of the other field(s), but only if our + // previous value matched the previous value of the other field(s) + oldValue = Object.values(oldValue).join(' ').trim(); + newValue = Object.values(newValue).join(' ').trim(); + if ((!this.wasPopulated && ((this.next == null) || (!this.next.length))) || (this.next === oldValue)) { + this.next = newValue; + } + } + }, + next() { + if (this.next && this.next.length) { + this.wasPopulated = true; + } + } + }, + mounted() { + this.defineStep(); + this.wasPopulated = this.next && this.next.length; + }, + methods: { + enterEmit() { + if (this.field.enterSubmittable) { + // Include the validated results in cases where an Enter keydown should + // act as submitting a form. + this.$emit('return', { + data: this.next, + error: this.validate(this.next) + }); + } else { + this.$emit('return'); + } + }, + validate(value) { + if (value == null) { + value = ''; + } + if (typeof value === 'string' && !value.length) { + // Also correct for float and integer because Vue coerces + // number fields to either a number or the empty string + return this.field.required ? 'required' : false; + } + + const minMaxFields = [ + 'integer', + 'float', + 'string', + 'date', + 'password' + ]; + + if (typeof value === 'string' && this.field.pattern) { + const regex = new RegExp(this.field.pattern); + if (!regex.test(value)) { + return 'invalid'; + } + } + + if (this.field.min && minMaxFields.includes(this.field.type)) { + if ((value != null) && value.length && (this.minMaxComparable(value) < this.field.min)) { + return 'min'; + } + } + if (this.field.max && minMaxFields.includes(this.field.type)) { + if ((value != null) && value.length && (this.minMaxComparable(value) > this.field.max)) { + return 'max'; + } + } + if (this.field.type === 'email' && value) { + // regex source: https://emailregex.com/ + const matches = value.match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/); + if (!matches) { + return 'invalid'; + } + } + return false; + }, + defineStep() { + if (this.type === 'number') { + this.step = this.field.type === 'float' ? 'any' : 1; + } + }, + convert(s) { + if (this.field.type === 'integer') { + if ((s == null) || (s === '')) { + return s; + } else { + return parseInt(s); + } + } else if (this.field.type === 'float') { + if ((s == null) || (s === '')) { + return s; + } else { + // The native parse float converts 3.0 to 3 and makes + // next to become integer. In theory we don't need parseFloat + // as the value is natively guarded by the browser 'number' type. + // However we need a float value sent to the backend + // and we force that when focus is lost. + if (this.focus && `${s}`.match(/\.[0]*$/)) { + return s; + } + return parseFloat(s); + } + } else { + if (s == null) { + return ''; + } else { + return s.toString(); + } + } + }, + minMaxComparable(s) { + const converted = this.convert(s); + if ([ 'integer', 'float', 'date', 'range', 'time' ].includes(this.field.type)) { + // Compare the actual values for these types + return converted; + } else { + // Compare the length for other types, like string or password or url + return converted.length; + } + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js b/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js new file mode 100644 index 0000000000..59f882aca8 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js @@ -0,0 +1,118 @@ +// A component designed to be used as a scaffold for AposInputString and +// friends, which override the `body` slot +export default { + name: 'AposInputWrapper', + inject: { + originalDoc: { + default: () => ({ + ref: null + }) + } + }, + props: { + field: { + type: Object, + required: true + }, + error: { + type: [ String, Boolean, Object ], + default: null + }, + uid: { + type: Number, + required: true + }, + modifiers: { + type: Array, + default() { + return []; + } + }, + items: { + type: Array, + default() { + return []; + } + }, + displayOptions: { + type: Object, + default() { + return {}; + } + } + }, + data () { + return { + wrapEl: 'div', + labelEl: 'label' + }; + }, + computed: { + label () { + const { label, publishedLabel } = this.field; + + if ( + this.originalDoc.ref && + this.originalDoc.ref.lastPublishedAt && + publishedLabel + ) { + return publishedLabel; + } + + return label; + }, + classList: function () { + const classes = [ + 'apos-field', + `apos-field--${this.field.type}`, + `apos-field--${this.field.name}` + ]; + if (this.field.classes) { + classes.push(this.field.classes); + } + if (this.errorClasses) { + classes.push(this.errorClasses); + } + if (this.modifiers) { + this.modifiers.forEach((m) => { + classes.push(`apos-field--${m}`); + }); + } + return classes; + }, + errorClasses: function () { + if (!this.error) { + return null; + } + + let error = 'unknown'; + + if (typeof this.error === 'string') { + error = this.error; + } else if (this.error.name) { + error = this.error.name; + } + + return `apos-field--error apos-field--error-${error}`; + }, + errorMessage () { + if (this.error) { + if (typeof this.error === 'string') { + return this.error; + } else if (this.error.message) { + return this.error.message; + } else { + return 'Error'; + } + } else { + return false; + } + } + }, + mounted: function () { + if (this.field.type === 'radio' || this.field.type === 'checkbox') { + this.wrapEl = 'fieldset'; + this.labelEl = 'legend'; + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js b/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js new file mode 100644 index 0000000000..8688ec3bef --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js @@ -0,0 +1,281 @@ +import { detectFieldChange } from 'Modules/@apostrophecms/schema/lib/detectChange'; + +export default { + name: 'AposSchema', + props: { + value: { + type: Object, + required: true + }, + generation: { + type: Number, + required: false, + default() { + return null; + } + }, + schema: { + type: Array, + required: true + }, + fieldStyle: { + type: String, + required: false, + default: '' + }, + currentFields: { + type: Array, + default() { + return null; + } + }, + followingValues: { + type: Object, + default() { + return {}; + } + }, + conditionalFields: { + type: Object, + default() { + return {}; + } + }, + modifiers: { + type: Array, + default() { + return []; + } + }, + triggerValidation: Boolean, + utilityRail: { + type: Boolean, + default() { + return false; + } + }, + docId: { + type: String, + default() { + return null; + } + }, + serverErrors: { + type: Object, + default() { + return null; + } + }, + displayOptions: { + type: Object, + default() { + return {}; + } + }, + changed: { + type: Array, + default() { + return []; + } + } + }, + emits: [ + 'input', + 'reset', + 'validate', + 'update-doc-data' + ], + data() { + return { + schemaReady: false, + next: { + hasErrors: false, + data: {}, + fieldErrors: {} + }, + fieldState: {}, + fieldComponentMap: window.apos.schema.components.fields || {} + }; + }, + computed: { + fields() { + const fields = {}; + this.schema.forEach(item => { + fields[item.name] = {}; + fields[item.name].field = item; + fields[item.name].value = { + data: this.value[item.name] + }; + fields[item.name].serverError = this.serverErrors && this.serverErrors[item.name]; + fields[item.name].modifiers = [ + ...(this.modifiers || []), + ...(item.modifiers || []) + ]; + }); + return fields; + } + }, + watch: { + fieldState: { + deep: true, + handler() { + this.updateNextAndEmit(); + } + }, + schema() { + this.populateDocData(); + }, + 'value.data._id'(_id) { + // The doc might be swapped out completely in cases such as the media + // library editor. Repopulate the fields if that happens. + if ( + // If the fieldState had been cleared and there's new populated data + (!this.fieldState._id && _id) || + // or if there *is* active fieldState, but the new data is a new doc + (this.fieldState._id && _id !== this.fieldState._id.data) + ) { + // repopulate the schema. + this.populateDocData(); + } + }, + generation() { + // repopulate the schema. + this.populateDocData(); + }, + conditionalFields(newVal, oldVal) { + for (const field in oldVal) { + if (!this.fieldState[field] || (newVal[field] === oldVal[field]) || !this.fieldState[field].ranValidation) { + continue; + } + + if ( + (newVal[field] === false) || + (newVal[field] && this.fieldState[field].ranValidation) + ) { + this.$emit('validate'); + } + } + } + }, + created() { + this.populateDocData(); + }, + methods: { + getDisplayOptions(fieldName) { + let options = {}; + if (this.displayOptions) { + options = { ...this.displayOptions }; + } + if (this.changed && this.changed.includes(fieldName)) { + options.changed = true; + } + return options; + }, + populateDocData() { + this.schemaReady = false; + const next = { + hasErrors: false, + data: {} + }; + + const fieldState = {}; + + // Though not in the schema, keep track of the _id field. + if (this.value.data._id) { + next.data._id = this.value.data._id; + fieldState._id = { data: this.value.data._id }; + } + // Though not *always* in the schema, keep track of the archived status. + if (this.value.data.archived !== undefined) { + next.data.archived = this.value.data.archived; + fieldState.archived = { data: this.value.data.archived }; + } + + this.schema.forEach(field => { + const value = this.value.data[field.name]; + fieldState[field.name] = { + error: false, + data: (value === undefined) ? field.def : value + }; + next.data[field.name] = fieldState[field.name].data; + }); + this.next = next; + this.fieldState = fieldState; + + // Wait until the next tick so the parent editor component is done + // updating. This is only really a concern in editors that can swap + // the active doc/object without unmounting AposSchema. + this.$nextTick(() => { + this.schemaReady = true; + // Signal that the schema data is ready to be tracked. + this.$emit('reset'); + }); + }, + updateNextAndEmit() { + if (!this.schemaReady) { + return; + } + const oldHasErrors = this.next.hasErrors; + // destructure these for non-linked comparison + const oldFieldState = { ...this.next.fieldState }; + const newFieldState = { ...this.fieldState }; + + let changeFound = false; + + this.next.hasErrors = false; + this.next.fieldState = { ...this.fieldState }; + + this.schema.filter(field => this.displayComponent(field)).forEach(field => { + if (this.fieldState[field.name].error) { + this.next.hasErrors = true; + } + if ( + this.fieldState[field.name].data !== undefined && + detectFieldChange(field, this.next.data[field.name], this.fieldState[field.name].data) + ) { + changeFound = true; + this.next.data[field.name] = this.fieldState[field.name].data; + } else { + this.next.data[field.name] = this.value.data[field.name]; + } + }); + if ( + oldHasErrors !== this.next.hasErrors || + oldFieldState !== newFieldState + ) { + // Otherwise the save button may never unlock + changeFound = true; + } + + if (changeFound) { + // ... removes need for deep watch at parent level + this.$emit('input', { ...this.next }); + } + }, + displayComponent({ name, hidden = false }) { + if (hidden === true) { + return false; + } + + if (this.currentFields && !this.currentFields.includes(name)) { + return false; + } + + // Might not be a conditional field at all, so test explicitly for false + if (this.conditionalFields[name] === false) { + return false; + } + + return true; + }, + scrollFieldIntoView(fieldName) { + // The refs for a name are an array if that ref was assigned + // in a v-for. We know there is only one in this case + // https://forum.vuejs.org/t/this-refs-theid-returns-an-array/31995/9 + this.$refs[fieldName][0].$el.scrollIntoView(); + }, + onUpdateDocData(data) { + this.$emit('update-doc-data', data); + } + } +}; diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposSearchList.js b/modules/@apostrophecms/schema/ui/apos/logic/AposSearchList.js new file mode 100644 index 0000000000..172d9dbef2 --- /dev/null +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposSearchList.js @@ -0,0 +1,249 @@ + + + + + From cc771c3ca9a1fbb3abc3ab8f3e24c562f04e1b03 Mon Sep 17 00:00:00 2001 From: Tristan K Date: Thu, 10 Aug 2023 13:26:27 -0600 Subject: [PATCH 2/6] refactor components to use logic files --- .../ui/apos/components/AposArrayEditor.vue | 359 +----------------- .../ui/apos/components/AposInputArea.vue | 88 +---- .../ui/apos/components/AposInputArray.vue | 256 +------------ .../apos/components/AposInputAttachment.vue | 79 +--- .../ui/apos/components/AposInputBoolean.vue | 46 +-- .../apos/components/AposInputCheckboxes.vue | 66 +--- .../ui/apos/components/AposInputColor.vue | 96 +---- .../apos/components/AposInputDateAndTime.vue | 50 +-- .../ui/apos/components/AposInputObject.vue | 84 +--- .../ui/apos/components/AposInputPassword.vue | 39 +- .../ui/apos/components/AposInputRadio.vue | 28 +- .../ui/apos/components/AposInputRange.vue | 59 +-- .../apos/components/AposInputRelationship.vue | 261 +------------ .../ui/apos/components/AposInputSelect.vue | 40 +- .../ui/apos/components/AposInputSlug.vue | 277 +------------- .../ui/apos/components/AposInputString.vue | 169 +-------- .../ui/apos/components/AposInputWrapper.vue | 117 +----- .../schema/ui/apos/components/AposSchema.vue | 282 +------------- .../ui/apos/components/AposSearchList.vue | 87 +---- 19 files changed, 41 insertions(+), 2442 deletions(-) diff --git a/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue b/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue index 8c60149643..42cc51cdd4 100644 --- a/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +++ b/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue @@ -84,366 +84,11 @@ diff --git a/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue b/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue index 429d06352a..c09fa6d0a0 100644 --- a/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +++ b/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue @@ -31,94 +31,10 @@ diff --git a/modules/@apostrophecms/schema/ui/apos/components/AposInputArray.vue b/modules/@apostrophecms/schema/ui/apos/components/AposInputArray.vue index 849726694a..544fa0a52b 100644 --- a/modules/@apostrophecms/schema/ui/apos/components/AposInputArray.vue +++ b/modules/@apostrophecms/schema/ui/apos/components/AposInputArray.vue @@ -140,263 +140,11 @@ From e457143b3a27954f4199f4e4bc2110e8e10962b3 Mon Sep 17 00:00:00 2001 From: Tristan K Date: Thu, 10 Aug 2023 13:42:33 -0600 Subject: [PATCH 4/6] names are hard --- .../schema/ui/apos/components/AposSearchList.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue b/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue index c8a81ab6b4..68c2949cac 100644 --- a/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +++ b/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue @@ -41,10 +41,10 @@ From 7686bdbddb35d3f3f789c2d25a1a34596f96c14c Mon Sep 17 00:00:00 2001 From: Tristan K Date: Thu, 10 Aug 2023 14:07:22 -0600 Subject: [PATCH 5/6] names are hard 2: harder names --- .../@apostrophecms/schema/ui/apos/components/AposSchema.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue b/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue index ff2f603b36..33adc73bd3 100644 --- a/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +++ b/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue @@ -56,10 +56,10 @@ From 2da0a23db32b3b8f3e9be7b47f2c15f8e8782cf9 Mon Sep 17 00:00:00 2001 From: Tristan K Date: Thu, 10 Aug 2023 14:27:22 -0600 Subject: [PATCH 6/6] fix capitalization --- .../schema/ui/apos/components/AposArrayEditor.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue b/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue index 42cc51cdd4..36947a8509 100644 --- a/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +++ b/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue @@ -84,11 +84,11 @@