From c00e258e385219fd3c6286219c897cab6bd06c73 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 19 May 2021 12:54:59 -0400 Subject: [PATCH] Frontend giraffeql boilerplate updates --- .../components/dialog/crudRecordDialog.vue | 94 ++++++++++ frontend/components/input/genericInput.vue | 175 ++++++++++++++++-- .../interface/crud/crudRecordInterface.vue | 136 ++++++++++++-- .../special/crudPersonalBestInterface.vue | 136 ++++++++++++-- frontend/components/menu/recordActionMenu.vue | 2 +- frontend/components/page/crudRecordPage.vue | 6 + .../components/page/preset/pbPagePreset.vue | 1 - frontend/mixins/crud.js | 89 ++++++++- frontend/mixins/editRecordInterface.js | 136 +++++++++----- frontend/models/user.ts | 3 + frontend/services/base.ts | 5 + frontend/types/index.ts | 44 +++-- 12 files changed, 720 insertions(+), 107 deletions(-) create mode 100644 frontend/components/dialog/crudRecordDialog.vue diff --git a/frontend/components/dialog/crudRecordDialog.vue b/frontend/components/dialog/crudRecordDialog.vue new file mode 100644 index 0000000..51d9761 --- /dev/null +++ b/frontend/components/dialog/crudRecordDialog.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/components/input/genericInput.vue b/frontend/components/input/genericInput.vue index 1fb0243..acbbb8a 100644 --- a/frontend/components/input/genericInput.vue +++ b/frontend/components/input/genericInput.vue @@ -3,6 +3,59 @@
+
- mdi-account + {{ + item.fieldInfo.inputOptions + ? item.fieldInfo.inputOptions.fallbackIcon + : null + }} + + > + + + + > + + + import Draggable from 'vuedraggable' // import { uploadFile } from '~/services/file' -import { capitalizeString, isObject, handleError } from '~/services/base' +import { + capitalizeString, + isObject, + handleError, + getIcon, +} from '~/services/base' import { executeGiraffeql } from '~/services/giraffeql' +// import FileChip from '~/components/chip/fileChip.vue' export default { components: { Draggable, + // FileChip, }, props: { item: { @@ -457,6 +601,11 @@ export default { isReadonly() { return this.item.readonly }, + icon() { + return this.item.fieldInfo.typename + ? getIcon(this.item.fieldInfo.typename) + : null + }, }, methods: { addRow() { @@ -522,18 +671,20 @@ export default { } }, - /* handleMultipleFileInputChange(inputObject) { + /* handleMultipleFileInputChange(inputObject, removeFromInput = true) { this.$set(inputObject, 'loading', true) // inputObject.input expected to be array inputObject.input.forEach((currentFile) => { uploadFile(this, currentFile, (file) => { - // add finished URL to value - inputObject.value.push(file.fileUploadObject.servingUrl) + // add finished fileRecord ID to value + inputObject.value.push(file.fileUploadObject.fileRecord) // remove file from input - const index = inputObject.input.indexOf(file) - if (index !== -1) inputObject.input.splice(index, 1) + if (removeFromInput) { + const index = inputObject.input.indexOf(file) + if (index !== -1) inputObject.input.splice(index, 1) + } // if no files left, finish up if (inputObject.input.length < 1) { @@ -545,7 +696,7 @@ export default { } }) }) - }, + }, */ handleSingleFileInputClear(inputObject) { inputObject.value = null @@ -559,7 +710,7 @@ export default { inputObject.loading = false }, - handleSingleFileInputChange(inputObject) { + /* handleSingleFileInputChange(inputObject) { if (!inputObject.input) { this.handleSingleFileInputClear(inputObject) return diff --git a/frontend/components/interface/crud/crudRecordInterface.vue b/frontend/components/interface/crud/crudRecordInterface.vue index 33fddb0..6178e17 100644 --- a/frontend/components/interface/crud/crudRecordInterface.vue +++ b/frontend/components/interface/crud/crudRecordInterface.vue @@ -19,7 +19,7 @@ @update:page="setTableOptionsUpdatedTrigger('page')" > + + > + + + + > + + + + + + > + + + + > + + + + {{ expandObject.name || item.recordInfo.name }} + >{{ expandObject.name || expandObject.recordInfo.name }} mdi-open-in-new diff --git a/frontend/components/page/crudRecordPage.vue b/frontend/components/page/crudRecordPage.vue index bb066f0..08c3ef1 100644 --- a/frontend/components/page/crudRecordPage.vue +++ b/frontend/components/page/crudRecordPage.vue @@ -12,8 +12,10 @@ :hidden-headers="hiddenHeaders" :title="title" :icon="icon" + :poll-interval="pollInterval" dense @pageOptions-updated="handlePageOptionsUpdated" + @record-changed="$emit('record-changed', $event)" > @@ -55,6 +57,10 @@ export default { type: Object, default: () => null, }, + pollInterval: { + type: Number, + default: 0, + }, }, computed: { interfaceComponent() { diff --git a/frontend/components/page/preset/pbPagePreset.vue b/frontend/components/page/preset/pbPagePreset.vue index 36e59f6..0c73d99 100644 --- a/frontend/components/page/preset/pbPagePreset.vue +++ b/frontend/components/page/preset/pbPagePreset.vue @@ -218,7 +218,6 @@ export default { this.inputs.event = this.events.find( (event) => event.id === eventFilterObject.value ) - console.log(this.inputs.event) } else { this.inputs.event = null } diff --git a/frontend/mixins/crud.js b/frontend/mixins/crud.js index 2e3cc1f..89dd556 100644 --- a/frontend/mixins/crud.js +++ b/frontend/mixins/crud.js @@ -18,6 +18,7 @@ import { serializeNestedProperty, getPaginatorData, collectPaginatorData, + getIcon, } from '~/services/base' export default { @@ -83,6 +84,10 @@ export default { type: Boolean, default: false, }, + pollInterval: { + type: Number, + default: 0, + }, }, data() { @@ -126,6 +131,9 @@ export default { tableOptionsUpdatedTrigger: null, + pollIntervalTimer: null, + isPolling: false, + processedUpdateSort: false, previousPage: null, @@ -265,6 +273,15 @@ export default { }, watch: { + isPolling(val, prevVal) { + if (val === prevVal) return + if (val) { + this.startPolling() + } else { + this.stopPolling() + } + }, + '$vuetify.breakpoint.name'(value) { if (value === 'xs') { // when switching to mobile view, un-expand all @@ -283,6 +300,16 @@ export default { }) }, + // this should trigger if the locked filters gets updated + lockedFilters() { + this.reset({ + resetSubscription: true, + initFilters: true, + resetSort: true, + resetCursor: true, + }) + }, + // this triggers when pageOptions get updated on parent element // this also triggers when parent element switches to a different item pageOptions() { @@ -303,20 +330,61 @@ export default { }, created() { + if (this.pollInterval > 0) this.isPolling = true + this.reset({ resetSubscription: true, initFilters: true, resetSort: true, }) + + document.addEventListener( + 'visibilitychange', + this.handleVisibilityChange, + false + ) }, destroyed() { // unsubscribe from channels on this page if (this.useSubscription) unsubscribeChannels(this.subscriptionChannels) + + this.stopPolling() + document.removeEventListener( + 'visibilitychange', + this.handleVisibilityChange + ) }, methods: { generateTimeAgoString, + getIcon, + + startPolling() { + // set the interval for refreshing, if pollInterval > 0 and not polling + if (this.pollInterval > 0 && !this.pollIntervalTimer) { + this.pollIntervalTimer = setInterval(() => { + this.reset() + }, this.pollInterval) + } + }, + + stopPolling() { + clearInterval(this.pollIntervalTimer) + this.pollIntervalTimer = null + }, + + handleVisibilityChange() { + if (!this.isPolling) return + if (document.hidden) { + // clear the pollIntervalTimer + this.stopPolling() + } else { + // start the pollIntervalTimer again + this.startPolling() + } + }, + setTableOptionsUpdatedTrigger(trigger) { this.tableOptionsUpdatedTrigger = trigger }, @@ -369,6 +437,9 @@ export default { node: { id: true, name: true, + ...(inputObject.fieldInfo.inputOptions?.hasAvatar && { + avatar: true, + }), }, }, __args: { @@ -764,8 +835,8 @@ export default { if (matchingInputObject) { // if inputType is server-X, do not apply the value unless init if ( - matchingInputObject.fieldInfo.inputType !== 'server-autocomplete' && - matchingInputObject.fieldInfo.inputType !== 'server-combobox' + matchingInputObject.inputType !== 'server-autocomplete' && + matchingInputObject.inputType !== 'server-combobox' ) { matchingInputObject.value = ele.value } @@ -773,9 +844,8 @@ export default { if (init) { // if inputType === 'server-X', only populate the options with the specific entry, if any if ( - matchingInputObject.fieldInfo.inputType === - 'server-autocomplete' || - matchingInputObject.fieldInfo.inputType === 'server-combobox' + matchingInputObject.inputType === 'server-autocomplete' || + matchingInputObject.inputType === 'server-combobox' ) { matchingInputObject.value = ele.value if (matchingInputObject.value) { @@ -785,6 +855,8 @@ export default { )}`]: { id: true, name: true, + ...(matchingInputObject.fieldInfo.inputOptions + ?.hasAvatar && { avatar: true }), __args: { id: ele.value, }, @@ -833,6 +905,7 @@ export default { resetCursor = false, resetExpanded = true, reloadData = true, + resetPolling = true, } = {}) { let actuallyReloadData = reloadData @@ -842,6 +915,11 @@ export default { if (this.useSubscription) this.subscribeEvents() } + if (resetPolling && this.isPolling) { + this.stopPolling() + this.startPolling() + } + if (initFilters) { this.filterInputsArray = this.recordInfo.paginationOptions.filters.map( (ele) => { @@ -855,6 +933,7 @@ export default { fieldInfo, title: ele.title, operator: ele.operator, + inputType: ele.inputType ?? fieldInfo.inputType, options: [], value: null, loading: false, diff --git a/frontend/mixins/editRecordInterface.js b/frontend/mixins/editRecordInterface.js index fc88125..d7b9c84 100644 --- a/frontend/mixins/editRecordInterface.js +++ b/frontend/mixins/editRecordInterface.js @@ -313,68 +313,101 @@ export default { this.mode === 'copy' ? this.recordInfo.addOptions.fields : fields // build inputs Array - this.inputsArray = inputFields.map((fieldKey) => { - const fieldInfo = this.recordInfo.fields[fieldKey] + this.inputsArray = await Promise.all( + inputFields.map(async (fieldKey) => { + const fieldInfo = this.recordInfo.fields[fieldKey] - // field unknown, abort - if (!fieldInfo) throw new Error('Unknown field: ' + fieldKey) + // field unknown, abort + if (!fieldInfo) throw new Error('Unknown field: ' + fieldKey) - let fieldValue + let fieldValue - // if copy mode and fieldKey not in original fields, use default - if (this.mode === 'copy' && !fields.includes(fieldKey)) { - fieldValue = fieldInfo.default ? fieldInfo.default(this) : null - } else { - fieldValue = fieldInfo.hidden - ? null - : getNestedProperty(data, fieldKey) - } - - const inputObject = { - field: fieldKey.split(/\+/)[0], - fieldInfo, - value: fieldValue, // already serialized - options: [], - readonly: - this.mode === 'view' - ? true - : this.mode === 'copy' - ? fields.includes(fieldKey) - : false, - } + // if copy mode and fieldKey not in original fields, use default + if (this.mode === 'copy' && !fields.includes(fieldKey)) { + fieldValue = fieldInfo.default ? fieldInfo.default(this) : null + } else { + fieldValue = fieldInfo.hidden + ? null + : getNestedProperty(data, fieldKey) + } - // if inputType === 'server-autocomplete', only populate the options with the specific entry, if any, and if inputObject.value not null - if ( - fieldInfo.inputType === 'server-autocomplete' || - fieldInfo.inputType === 'server-combobox' - ) { - inputObject.value = null // set this to null initially while the results load, to prevent console error - if (fieldValue) { - executeGiraffeql(this, { - [`get${capitalizeString(fieldInfo.typename)}`]: { - id: true, - name: true, + // if field is 'multiple-file' inputType, retrieve the file data from api + if ( + fieldInfo.inputType === 'multiple-file' && + fieldValue.length > 0 + ) { + const fileData = await executeGiraffeql(this, { + getFilePaginator: { + edges: { + node: { + id: true, + name: true, + size: true, + location: true, + }, + }, __args: { - id: fieldValue, + first: 100, + filterBy: [ + { + id: { + in: fieldValue, + }, + }, + ], }, }, }) - .then((res) => { - // change value to object - inputObject.value = res + fieldValue = fileData.edges.map((ele) => ele.node) + } - inputObject.options = [res] + const inputObject = { + field: fieldKey.split(/\+/)[0], + fieldInfo, + value: fieldValue, // already serialized + options: [], + readonly: + this.mode === 'view' + ? true + : this.mode === 'copy' + ? fields.includes(fieldKey) + : false, + } + + // if inputType === 'server-autocomplete', only populate the options with the specific entry, if any, and if inputObject.value not null + if ( + fieldInfo.inputType === 'server-autocomplete' || + fieldInfo.inputType === 'server-combobox' + ) { + inputObject.value = null // set this to null initially while the results load, to prevent console error + if (fieldValue) { + executeGiraffeql(this, { + [`get${capitalizeString(fieldInfo.typename)}`]: { + id: true, + name: true, + ...(fieldInfo.inputOptions?.hasAvatar && { avatar: true }), + __args: { + id: fieldValue, + }, + }, }) - .catch((e) => e) + .then((res) => { + // change value to object + inputObject.value = res + + inputObject.options = [res] + }) + .catch((e) => e) + } + } else { + fieldInfo.getOptions && + fieldInfo + .getOptions(this) + .then((res) => (inputObject.options = res)) } - } else { - fieldInfo.getOptions && - fieldInfo - .getOptions(this) - .then((res) => (inputObject.options = res)) - } - return inputObject - }) + return inputObject + }) + ) } catch (err) { handleError(this, err) } @@ -443,6 +476,7 @@ export default { [`get${capitalizeString(fieldInfo.typename)}`]: { id: true, name: true, + ...(fieldInfo.inputOptions?.hasAvatar && { avatar: true }), __args: { id: inputObject.value, }, diff --git a/frontend/models/user.ts b/frontend/models/user.ts index 3e52f2e..16aa893 100644 --- a/frontend/models/user.ts +++ b/frontend/models/user.ts @@ -34,6 +34,9 @@ export const User = >{ avatar: { text: 'Avatar URL', inputType: 'avatar', + inputOptions: { + fallbackIcon: 'mdi-account', + }, }, country: { text: 'Country', diff --git a/frontend/services/base.ts b/frontend/services/base.ts index c698e4a..84e22a2 100644 --- a/frontend/services/base.ts +++ b/frontend/services/base.ts @@ -1,9 +1,14 @@ import { format } from 'timeago.js' import { convertArrayToCSV } from 'convert-array-to-csv' import { executeGiraffeql } from '~/services/giraffeql' +import * as models from '~/models' type StringKeyObject = { [x: string]: any } +export function getIcon(typename: string) { + return models[capitalizeString(typename)]?.icon +} + export function generateTimeAgoString(unixTimestamp: number | null) { if (!unixTimestamp) return 'None' diff --git a/frontend/types/index.ts b/frontend/types/index.ts index 6c3c9d4..99df8c6 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -30,21 +30,16 @@ export type RecordInfo = { // which field should the primary field, for sort purposes primaryField: string } - inputType?: - | 'html' - | 'single-image' - | 'multiple-image' - | 'key-value-array' - | 'avatar' - | 'datepicker' - | 'switch' - | 'textarea' - | 'combobox' // combobox allows the user to add new inputs on the fly (will change to autocomplete in filter interfaces) - | 'server-combobox' // server-combobox allows the user to add new inputs on the fly with getOptions optional, and fetching results from server (will change to autocomplete in filter interfaces) - | 'autocomplete' // same as combobox but cannot add new inputs - | 'server-autocomplete' // if there's lots of entries, may not want to fetch all of the entries at once. getOptions will be optional - | 'select' // standard select - | 'text' + inputType?: InputType + + // special options pertaining to the specific inputType + inputOptions?: { + // for server-autocomplete and server-combobox + hasAvatar?: boolean + // for avatar + fallbackIcon?: string + } + inputRules?: any[] getOptions?: (that) => Promise typename?: string @@ -199,4 +194,23 @@ type FilterObject = { type RecordFilter = { field: keyof T operator: keyof FilterByField + inputType?: InputType } + +type InputType = + | 'html' + | 'single-image' + | 'multiple-image' + | 'multiple-file' + | 'key-value-array' + | 'avatar' + | 'datepicker' + | 'switch' + | 'textarea' + | 'combobox' // combobox allows the user to add new inputs on the fly (will change to autocomplete in filter interfaces) + | 'server-combobox' // server-combobox allows the user to add new inputs on the fly with getOptions optional, and fetching results from server (will change to autocomplete in filter interfaces) + | 'autocomplete' // same as combobox but cannot add new inputs + | 'server-autocomplete' // if there's lots of entries, may not want to fetch all of the entries at once. getOptions will be optional + | 'select' // standard select + | 'multiple-select' // multiple select + | 'text'