diff --git a/backend/functions/db/migrations/20210303180546_happened_on_to_datetime.ts b/backend/functions/db/migrations/20210303180546_happened_on_to_datetime.ts new file mode 100644 index 0000000..8666f18 --- /dev/null +++ b/backend/functions/db/migrations/20210303180546_happened_on_to_datetime.ts @@ -0,0 +1,13 @@ +import * as Knex from "knex"; + +export async function up(knex: Knex): Promise { + return knex.schema.alterTable("personalBest", function (t) { + t.dateTime("happened_on").notNullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.alterTable("personalBest", function (t) { + t.date("happened_on").notNullable().alter(); + }); +} diff --git a/backend/functions/migration.ts b/backend/functions/migration.ts index 0348508..17c2a07 100644 --- a/backend/functions/migration.ts +++ b/backend/functions/migration.ts @@ -53,7 +53,7 @@ export async function up(knex: Knex): Promise { table.integer("attempts_succeeded").notNullable(); table.integer("attempts_total").notNullable(); table.integer("product").nullable(); - table.date("happened_on").notNullable(); + table.dateTime("happened_on").notNullable(); table.integer("time_elapsed").notNullable(); table.dateTime("created_at").notNullable().defaultTo(knex.fn.now()); table.dateTime("updated_at").nullable(); diff --git a/backend/functions/package-lock.json b/backend/functions/package-lock.json index c0c6349..09f6758 100644 --- a/backend/functions/package-lock.json +++ b/backend/functions/package-lock.json @@ -2582,9 +2582,9 @@ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, "jomql": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/jomql/-/jomql-0.3.12.tgz", - "integrity": "sha512-Bh3v5VxorBSkMK3JBVNj8tOLI3t5bClu2uCX+IQI3vJN2MppsXG18HlBGNb6YNpV1BpgZjSrEHF03sHHOn7bvA==" + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/jomql/-/jomql-0.3.13.tgz", + "integrity": "sha512-jKztvMtHqAnv3Q08C7TMqAlKC7gOcfXsrlnATwbzkqo98XymneFKyvEXc25zWH0y88ZCEdh+U9FB0lnJVnBpnQ==" }, "js-tokens": { "version": "4.0.0", diff --git a/backend/functions/package.json b/backend/functions/package.json index e1ce6f2..f2b2e2d 100644 --- a/backend/functions/package.json +++ b/backend/functions/package.json @@ -22,7 +22,7 @@ "express": "^4.17.1", "firebase-admin": "^9.2.0", "firebase-functions": "^3.13.1", - "jomql": "^0.3.12", + "jomql": "^0.3.13", "jsonwebtoken": "^8.5.1", "knex": "^0.21.17", "pg": "^8.5.1", diff --git a/backend/functions/src/schema/helpers/typeDef.ts b/backend/functions/src/schema/helpers/typeDef.ts index ea2eab1..5614630 100644 --- a/backend/functions/src/schema/helpers/typeDef.ts +++ b/backend/functions/src/schema/helpers/typeDef.ts @@ -153,8 +153,8 @@ export function generateStringField( }); } -// as UNIX timestamp -export function generateDateTimeField( +// DateTime as UNIX timestamp +export function generateUnixTimestampField( params: { nowOnly?: boolean; // if the unix timestamp can only be set to now() } & GenerateFieldParams @@ -184,7 +184,6 @@ export function generateDateTimeField( parseValue: nowOnly ? () => knex.fn.now() : (value: unknown) => { - console.log(value); if (typeof value !== "number") throw 1; // should never happen return new Date(value); }, @@ -426,7 +425,7 @@ export function generateEnumField( */ export function generateCreatedAtField() { - return generateDateTimeField({ + return generateUnixTimestampField({ name: "created_at", description: "When the record was created", allowNull: false, @@ -436,7 +435,7 @@ export function generateCreatedAtField() { } export function generateUpdatedAtField() { - return generateDateTimeField({ + return generateUnixTimestampField({ name: "updated_at", description: "When the record was last updated", allowNull: true, diff --git a/backend/functions/src/schema/models/personalBest/service.ts b/backend/functions/src/schema/models/personalBest/service.ts index 0b51918..96c80fa 100644 --- a/backend/functions/src/schema/models/personalBest/service.ts +++ b/backend/functions/src/schema/models/personalBest/service.ts @@ -1,9 +1,6 @@ import { PaginatedService } from "../../core/services"; -import { generateUserRoleGuard } from "../../helpers/permissions"; -import { userRoleKenum } from "../../enums"; import { permissionsCheck } from "../../helpers/permissions"; import * as Resolver from "../../helpers/resolver"; -import * as errorHelper from "../../helpers/error"; import * as sqlHelper from "../../helpers/sql"; import { ServiceFunctionInputs } from "../../../types"; import { JomqlBaseError } from "jomql"; @@ -29,6 +26,11 @@ export class PersonalBestService extends PaginatedService { id: {}, created_at: {}, score: {}, + "event.name": {}, + "pb_class.name": {}, + set_size: {}, + time_elapsed: {}, + happened_on: {}, }; searchFieldsMap = { @@ -208,7 +210,7 @@ export class PersonalBestService extends PaginatedService { fieldPath, options: { onConflict: { - columns: ["pb_class", "event", "set_size"], + columns: ["pb_class", "event", "set_size", "created_by"], action: "merge", }, }, diff --git a/backend/functions/src/schema/models/personalBest/typeDef.ts b/backend/functions/src/schema/models/personalBest/typeDef.ts index 3f03bc7..c239245 100644 --- a/backend/functions/src/schema/models/personalBest/typeDef.ts +++ b/backend/functions/src/schema/models/personalBest/typeDef.ts @@ -10,11 +10,10 @@ import { generateIdField, generateCreatedAtField, generateUpdatedAtField, - generateCreatedByField, generateTypenameField, generateJoinableField, generateIntegerField, - generateDateField, + generateUnixTimestampField, } from "../../helpers/typeDef"; export default new JomqlObjectType({ @@ -59,7 +58,7 @@ export default new JomqlObjectType({ service: Product, allowNull: true, }), - happened_on: generateDateField({ + happened_on: generateUnixTimestampField({ allowNull: false, }), time_elapsed: generateIntegerField({ diff --git a/frontend/components/interface/crud/crudRecordInterface.vue b/frontend/components/interface/crud/crudRecordInterface.vue index c8d6147..d087a8e 100644 --- a/frontend/components/interface/crud/crudRecordInterface.vue +++ b/frontend/components/interface/crud/crudRecordInterface.vue @@ -55,7 +55,12 @@ - + mdi-download @@ -66,7 +71,7 @@ diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index c5f6ec7..e3f4c04 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -43,6 +43,23 @@ + + + + + + {{ item.icon }} + + + + + - @@ -285,8 +216,12 @@ import { mapGetters } from 'vuex' import Snackbar from '~/components/snackbar/snackbar' import { goToWcaAuth, handleLogout } from '~/services/auth' -import sharedService from '~/services/shared' -import { copyToClipboard, openLink, capitalizeString } from '~/services/common' +import { + copyToClipboard, + openLink, + capitalizeString, + handleError, +} from '~/services/common' import * as models from '~/models' export default { @@ -305,13 +240,14 @@ export default { to: '/', }, ], - navItems: [ + userItems: [ { icon: 'mdi-timer', title: 'My PBs', to: '/my-pbs', - loginRequired: true, }, + ], + navItems: [ { icon: 'mdi-account', title: 'Public Users', @@ -357,8 +293,8 @@ export default { title: 'Administration', permissions: [], items: Object.values(models).map((recordInfo) => ({ - title: capitalizeString(recordInfo.pluralName), - to: '/admin/' + recordInfo.pluralType, + title: capitalizeString(recordInfo.pluralTypename), + to: '/admin/' + recordInfo.pluralTypename, roles: ['ADMIN'], permissions: [], })), @@ -397,7 +333,7 @@ export default { handleLogout(this) } catch (err) { - sharedService.handleError(err, this.$root) + handleError(this, err) } }, diff --git a/frontend/mixins/crud.js b/frontend/mixins/crud.js index 9f7f3e7..19da3fa 100644 --- a/frontend/mixins/crud.js +++ b/frontend/mixins/crud.js @@ -1,4 +1,3 @@ -import sharedService from '~/services/shared' import { executeJomql, executeJomqlSubscription } from '~/services/jomql' import { unsubscribeChannels } from '~/services/pusher' import CrudRecordInterface from '~/components/interface/crud/crudRecordInterface.vue' @@ -8,6 +7,9 @@ import { generateTimeAgoString, capitalizeString, isObject, + getCurrentDate, + downloadCSV, + handleError, } from '~/services/common' export default { @@ -45,7 +47,7 @@ export default { type: Array, default: () => [], }, - /** raw filters that do not need to be in recordInfo.filters. appended directly to the filterBy params. also applied to addRecordDialog + /** raw filters must also be in recordInfo.filters. appended directly to the filterBy params. also applied to addRecordDialog { field: string; operator: string; @@ -90,12 +92,6 @@ export default { filterOptions: {}, dialogs: { - /* viewRecord: false, - addRecord: false, - editRecord: false, - deleteRecord: false, - shareRecord: false, */ - editRecord: false, selectedItem: null, editMode: 'view', @@ -151,12 +147,12 @@ export default { computed: { childInterfaceComponent() { return this.expandTypeObject - ? this.expandTypeObject.recordInfo.interfaceComponent || - CrudRecordInterface + ? this.expandTypeObject.recordInfo.paginationOptions + .interfaceComponent || CrudRecordInterface : null }, capitalizedType() { - return capitalizeString(this.recordInfo.type) + return capitalizeString(this.recordInfo.typename) }, visibleFiltersArray() { return this.filterInputsArray.filter( @@ -170,7 +166,7 @@ export default { }, headers() { - return this.recordInfo.headers + return this.recordInfo.paginationOptions.headers .filter((headerInfo) => !this.hiddenHeaders.includes(headerInfo.field)) .map((headerInfo) => { const fieldInfo = this.recordInfo.fields[headerInfo.field] @@ -193,7 +189,7 @@ export default { sortable: false, value: null, width: '110px', - ...this.recordInfo.headerActionOptions, + ...this.recordInfo.paginationOptions.headerActionOptions, }) }, @@ -214,7 +210,7 @@ export default { return [ { - field: this.recordInfo.type.toLowerCase() + '.id', + field: this.recordInfo.typename.toLowerCase() + '.id', operator: 'eq', value: this.expandedItems[0].id, }, @@ -225,7 +221,7 @@ export default { if (!this.expandedItems.length) return [] // is there an excludeFilters array on the expandTypeObject? if so, use that - return [this.recordInfo.type.toLowerCase() + '.id'].concat( + return [this.recordInfo.typename.toLowerCase() + '.id'].concat( this.expandTypeObject.excludeFilters ?? [] ) }, @@ -235,7 +231,10 @@ export default { }, hasFilters() { - return this.recordInfo.filters.length > 0 || this.recordInfo.hasSearch + return ( + this.recordInfo.paginationOptions.filters.length > 0 || + this.recordInfo.paginationOptions.hasSearch + ) }, }, @@ -288,7 +287,7 @@ export default { inputObject.loading = true try { const results = await executeJomql(this, { - [`get${capitalizeString(inputObject.fieldInfo.type)}Paginator`]: { + [`get${capitalizeString(inputObject.fieldInfo.typename)}Paginator`]: { edges: { node: { id: true, @@ -304,7 +303,7 @@ export default { inputObject.options = results.edges.map((edge) => edge.node) } catch (err) { - sharedService.handleError(err, this.$root) + handleError(this, err) } inputObject.loading = false }, @@ -337,8 +336,8 @@ export default { }, handleRowClick(item) { - if (this.recordInfo.handleRowClick) - this.recordInfo.handleRowClick(this, item) + if (this.recordInfo.paginationOptions.handleRowClick) + this.recordInfo.paginationOptions.handleRowClick(this, item) }, getTableRowData(headerItem, item) { @@ -352,20 +351,35 @@ export default { // fetch data const results = await this.getRecords(false) - const data = results.edges.map((ele) => ele.node) + const data = results.edges + .map((ele) => ele.node) + .map((item) => { + const returnItem = {} + this.headers.forEach((headerObject) => { + if (headerObject.value) { + returnItem[headerObject.value] = this.getTableRowData( + headerObject, + item + ) + } + }) + return returnItem + }) + + console.log(data) if (data.length < 1) { - throw sharedService.generateError('No results to export') + throw new Error('No results to export') } // download as CSV - sharedService.downloadCSV( + downloadCSV( this, data, - 'Export' + this.capitalizedType + sharedService.getCurrentDate() + 'Export' + this.capitalizedType + getCurrentDate() ) } catch (err) { - sharedService.handleError(err, this.$root) + handleError(this, err) } this.loading.exportData = false }, @@ -448,6 +462,10 @@ export default { : { first: 100, // first 100 rows only } + + // create a map field -> serializeFn for fast serialization + const serializeMap = new Map() + const data = await executeJomql(this, { ['get' + this.capitalizedType + 'Paginator']: { paginatorInfo: { @@ -457,7 +475,7 @@ export default { }, edges: { node: collapseObject( - this.recordInfo.headers.reduce( + this.recordInfo.paginationOptions.headers.reduce( (total, headerInfo) => { const fieldInfo = this.recordInfo.fields[headerInfo.field] @@ -466,11 +484,19 @@ export default { throw new Error('Unknown field: ' + headerInfo.field) total[fieldInfo.mainField ?? headerInfo.field] = true + serializeMap.set( + fieldInfo.mainField ?? headerInfo.field, + fieldInfo.serialize + ) // if fieldInfo.requiredFields, those fields must also be requested if (fieldInfo.requiredFields) { fieldInfo.requiredFields.forEach((field) => { total[field] = true + // assuming the field is valid + serializeMap[field] = this.recordInfo.fields[ + field + ].serialize }) } return total @@ -487,10 +513,21 @@ export default { filterBy: [ this.filters.concat(this.lockedFilters).reduce((total, ele) => { if (!total[ele.field]) total[ele.field] = {} - // assuming this value has been parsed already - // however, still need to parse '__null' to null - total[ele.field][ele.operator] = - ele.value === '__null' ? null : ele.value + + // check if there is a parser on the fieldInfo + const fieldInfo = this.recordInfo.fields[ele.field] + + // field unknown, abort + if (!fieldInfo) throw new Error('Unknown field: ' + ele.field) + + // parse '__null' to null first + const value = ele.value === '__null' ? null : ele.value + + // apply parseValue function, if any + total[ele.field][ele.operator] = fieldInfo.parseValue + ? fieldInfo.parseValue(value) + : value + return total }, {}), ], @@ -500,6 +537,18 @@ export default { }, }) + // remove any undefined serializeMap elements + serializeMap.forEach((val, key) => { + if (val === undefined) serializeMap.delete(key) + }) + + // apply serialization to results + data.edges.forEach((ele) => { + serializeMap.forEach((serialzeFn, field) => { + ele.node[field] = serialzeFn(ele.node[field]) + }) + }) + return data }, @@ -510,9 +559,11 @@ export default { this.records = results.edges.map((ele) => ele.node) + // serialize any fields if necessary + this.nextPaginatorInfo = results.paginatorInfo } catch (err) { - sharedService.handleError(err, this.$root) + handleError(this, err) } this.loading.loadData = false }, @@ -521,7 +572,7 @@ export default { const channelName = await executeJomqlSubscription( this, { - [this.recordInfo.type + 'ListUpdated']: { + [this.recordInfo.typename + 'ListUpdated']: { id: true, __args: {}, }, @@ -567,7 +618,7 @@ export default { if (matchingInputObject.value) { executeJomql(this, { [`get${capitalizeString( - matchingInputObject.fieldInfo.type + matchingInputObject.fieldInfo.typename )}`]: { id: true, name: true, @@ -617,31 +668,33 @@ export default { } if (initFilters) { - this.filterInputsArray = this.recordInfo.filters.map((ele) => { - const fieldInfo = this.recordInfo.fields[ele.field] + this.filterInputsArray = this.recordInfo.paginationOptions.filters.map( + (ele) => { + const fieldInfo = this.recordInfo.fields[ele.field] + + // field unknown, abort + if (!fieldInfo) throw new Error('Unknown field: ' + ele.field) + + const filterObject = { + field: ele.field, + fieldInfo, + title: ele.title, + operator: ele.operator, + options: [], + value: null, + loading: false, + search: null, + focused: false, + } - // field unknown, abort - if (!fieldInfo) throw new Error('Unknown field: ' + ele.field) + fieldInfo.getOptions && + fieldInfo + .getOptions(this) + .then((res) => (filterObject.options = res)) - const filterObject = { - field: ele.field, - fieldInfo, - title: ele.title, - operator: ele.operator, - options: [], - value: null, - loading: false, - search: null, - focused: false, + return filterObject } - - fieldInfo.getOptions && - fieldInfo - .getOptions(this) - .then((res) => (filterObject.options = res)) - - return filterObject - }) + ) // clears the searchInput this.searchInput = '' @@ -653,10 +706,10 @@ export default { if (resetSort) { this.options.initialLoad = true // populate sort/page options - if (this.recordInfo.options?.sortBy) { - this.options.sortBy = this.recordInfo.options.sortBy - this.options.sortDesc = this.recordInfo.options.sortDesc - } + this.options.sortBy = + this.recordInfo.paginationOptions.sortOptions?.sortBy ?? [] + this.options.sortDesc = + this.recordInfo.paginationOptions.sortOptions?.sortDesc ?? [] } // sets all of the filter values to null, searchInput to '' and also emits changes to parent diff --git a/frontend/mixins/editRecordInterface.js b/frontend/mixins/editRecordInterface.js index f8d1016..1d96b0d 100644 --- a/frontend/mixins/editRecordInterface.js +++ b/frontend/mixins/editRecordInterface.js @@ -1,10 +1,10 @@ -import sharedService from '~/services/shared' import { executeJomql } from '~/services/jomql' import { collapseObject, getNestedProperty, capitalizeString, isObject, + handleError, } from '~/services/common' export default { @@ -55,7 +55,7 @@ export default { computed: { capitalizedType() { - return capitalizeString(this.recordInfo.type) + return capitalizeString(this.recordInfo.typename) }, title() { return ( @@ -121,7 +121,7 @@ export default { inputObject.loading = true try { const results = await executeJomql(this, { - [`get${capitalizeString(inputObject.fieldInfo.type)}Paginator`]: { + [`get${capitalizeString(inputObject.fieldInfo.typename)}Paginator`]: { edges: { node: { id: true, @@ -137,7 +137,7 @@ export default { inputObject.options = results.edges.map((edge) => edge.node) } catch (err) { - sharedService.handleError(err, this.$root) + handleError(this, err) } inputObject.loading = false }, @@ -163,13 +163,13 @@ export default { if ( (inputObject.fieldInfo.inputType === 'combobox' || inputObject.fieldInfo.inputType === 'server-combobox') && - inputObject.fieldInfo.type + inputObject.fieldInfo.typename ) { if (typeof inputObject.value === 'string') { // expecting either string or obj // create the item, get its id. const results = await executeJomql(this, { - ['create' + capitalizeString(inputObject.fieldInfo.type)]: { + ['create' + capitalizeString(inputObject.fieldInfo.typename)]: { id: true, name: true, __args: { @@ -237,7 +237,7 @@ export default { this.$emit('handleSubmit', data) } catch (err) { - sharedService.handleError(err, this.$root) + handleError(this, err) } this.loading.editRecord = false }, @@ -270,7 +270,6 @@ export default { }) // serialize any fields if necessary - this.inputsArray = fields.map((fieldKey) => { const fieldInfo = this.recordInfo.fields[fieldKey] @@ -297,7 +296,7 @@ export default { inputObject.value = null // set this to null initially while the results load, to prevent console error if (fieldValue) { executeJomql(this, { - [`get${capitalizeString(fieldInfo.type)}`]: { + [`get${capitalizeString(fieldInfo.typename)}`]: { id: true, name: true, __args: { @@ -322,7 +321,7 @@ export default { return inputObject }) } catch (err) { - sharedService.handleError(err, this.$root) + handleError(this, err) } this.loading.loadRecord = false }, @@ -386,7 +385,7 @@ export default { // only if readonly and value is truthy if (inputObject.readonly && inputObject.value) { executeJomql(this, { - [`get${capitalizeString(fieldInfo.type)}`]: { + [`get${capitalizeString(fieldInfo.typename)}`]: { id: true, name: true, __args: { diff --git a/frontend/models/event.ts b/frontend/models/event.ts index c7dfcd4..846f1f8 100644 --- a/frontend/models/event.ts +++ b/frontend/models/event.ts @@ -2,18 +2,12 @@ import type { RecordInfo } from '~/types' import TimeagoColumn from '~/components/table/common/timeagoColumn.vue' export const Event = >{ - type: 'event', - pluralType: 'events', + typename: 'event', + pluralTypename: 'events', name: 'Event', pluralName: 'Events', icon: 'mdi-view-grid', renderItem: (item) => item.name, - options: { - sortBy: ['created_at'], - sortDesc: [true], - }, - hasSearch: true, - filters: [], fields: { id: { text: 'ID', @@ -36,6 +30,41 @@ export const Event = >{ component: TimeagoColumn, }, }, + paginationOptions: { + sortOptions: { + sortBy: ['created_at'], + sortDesc: [true], + }, + hasSearch: true, + filters: [], + headers: [ + { + field: 'name', + sortable: true, + }, + { + field: 'max_attempts', + sortable: false, + width: '150px', + }, + { + field: 'code', + sortable: false, + width: '100px', + }, + { + field: 'created_at', + width: '150px', + sortable: true, + }, + { + field: 'updated_at', + width: '150px', + sortable: true, + }, + ], + downloadOptions: {}, + }, addOptions: { fields: ['name', 'code', 'max_attempts'], }, @@ -47,31 +76,6 @@ export const Event = >{ }, deleteOptions: {}, shareOptions: undefined, - headers: [ - { - field: 'name', - sortable: true, - }, - { - field: 'max_attempts', - sortable: false, - width: '150px', - }, - { - field: 'code', - sortable: false, - width: '100px', - }, - { - field: 'created_at', - width: '150px', - sortable: true, - }, - { - field: 'updated_at', - width: '150px', - sortable: true, - }, - ], + expandTypes: [], } diff --git a/frontend/models/personalBest.ts b/frontend/models/personalBest.ts index 9030f3e..c65d6f4 100644 --- a/frontend/models/personalBest.ts +++ b/frontend/models/personalBest.ts @@ -5,51 +5,17 @@ import { } from '../services/dropdown' import type { RecordInfo } from '~/types' import TimeagoColumn from '~/components/table/common/timeagoColumn.vue' -import TimeElapsedColumn from '~/components/table/common/timeElapsedColumn.vue' import CreatedByColumn from '~/components/table/common/createdByColumn.vue' import { serializeTime } from '~/services/common' export const PersonalBest = >{ - type: 'personalBest', - pluralType: 'personalBests', + typename: 'personalBest', + pluralTypename: 'personalBests', name: 'Personal Best', pluralName: 'Personal Bests', // viewRecordRoute: '/pb', icon: 'mdi-timer', renderItem: (item) => item.name, - options: { - sortBy: ['created_at'], - sortDesc: [true], - }, - hasSearch: false, - filters: [ - { - field: 'event.id', - operator: 'eq', - }, - { - field: 'pb_class.id', - operator: 'eq', - }, - { - field: 'product.id', - operator: 'eq', - }, - { - field: 'created_by.id', - operator: 'eq', - }, - { - field: 'happened_on', - title: 'Happened After', - operator: 'gt', - }, - { - field: 'happened_on', - title: 'Happened Before', - operator: 'lt', - }, - ], fields: { id: { text: 'ID', @@ -62,9 +28,9 @@ export const PersonalBest = >{ }, 'pb_class.id': { text: 'PB Type', - parseValue: (val) => Number(val), + parseQueryValue: (val) => Number(val), getOptions: getPersonalBestClasses, - type: 'personalBestClass', + typename: 'personalBestClass', inputType: 'select', }, 'pb_class.name': { @@ -72,7 +38,7 @@ export const PersonalBest = >{ }, 'event.id': { text: 'Event', - parseValue: (val) => Number(val), + parseQueryValue: (val) => Number(val), getOptions: getEvents, optionsType: 'event', inputType: 'autocomplete', @@ -89,7 +55,7 @@ export const PersonalBest = >{ 'product.id': { text: 'Cube', getOptions: getProducts, - type: 'product', + typename: 'product', inputType: 'server-combobox', optional: true, }, @@ -134,7 +100,6 @@ export const PersonalBest = >{ // round to tens return (1000 * Math.floor(seconds * 100)) / 100 }, - component: TimeElapsedColumn, }, attempts_succeeded: { text: 'Total Succeeded', @@ -147,12 +112,20 @@ export const PersonalBest = >{ happened_on: { text: 'Date Happened', inputType: 'datepicker', + // unix timestamp to YYYY-MM-DD + serialize: (val: number) => + val && new Date(val * 1000).toISOString().substring(0, 10), + // YYYY-MM-DD to unix timestamp + parseValue: (val: string) => val && new Date(val).getTime() / 1000, }, 'created_by.id': { text: 'Created By', inputType: 'server-autocomplete', - type: 'user', - parseValue: (val) => Number(val), + typename: 'user', + parseQueryValue: (val) => Number(val), + }, + 'created_by.is_public': { + text: 'Created By - Public', }, created_at: { text: 'Created At', @@ -163,6 +136,91 @@ export const PersonalBest = >{ component: TimeagoColumn, }, }, + paginationOptions: { + sortOptions: { + sortBy: ['created_at'], + sortDesc: [true], + }, + hasSearch: false, + + filters: [ + { + field: 'event.id', + operator: 'eq', + }, + { + field: 'pb_class.id', + operator: 'eq', + }, + { + field: 'product.id', + operator: 'eq', + }, + { + field: 'created_by.id', + operator: 'eq', + }, + { + field: 'happened_on', + title: 'Happened After', + operator: 'gt', + }, + { + field: 'happened_on', + title: 'Happened Before', + operator: 'lt', + }, + ], + + headers: [ + { + field: 'event.name', + sortable: true, + width: '100px', + }, + { + field: 'pb_class.name', + sortable: true, + width: '100px', + }, + { + field: 'set_size', + sortable: true, + width: '125px', + }, + { + field: 'time_elapsed', + sortable: true, + width: '125px', + }, + { + field: 'attempts_succeeded', + sortable: false, + width: '150px', + }, + { + field: 'attempts_total', + sortable: false, + width: '150px', + }, + { + field: 'happened_on', + sortable: true, + width: '150px', + }, + { + field: 'created_by.name+created_by.avatar', + sortable: false, + width: '200px', + }, + { + field: 'score', + sortable: true, + }, + ], + downloadOptions: {}, + }, + addOptions: { fields: [ 'event.id', @@ -192,51 +250,6 @@ export const PersonalBest = >{ }, deleteOptions: {}, shareOptions: {}, - headers: [ - { - field: 'event.name', - sortable: false, - width: '75px', - }, - { - field: 'pb_class.name', - sortable: true, - width: '100px', - }, - { - field: 'set_size', - sortable: true, - width: '125px', - }, - { - field: 'time_elapsed', - sortable: true, - width: '125px', - }, - { - field: 'attempts_succeeded', - sortable: true, - width: '150px', - }, - { - field: 'attempts_total', - sortable: true, - width: '150px', - }, - { - field: 'happened_on', - sortable: true, - width: '150px', - }, - { - field: 'created_by.name+created_by.avatar', - sortable: false, - width: '200px', - }, - { - field: 'score', - sortable: true, - }, - ], + expandTypes: [], } diff --git a/frontend/models/personalBestClass.ts b/frontend/models/personalBestClass.ts index 768fef0..c5745b9 100644 --- a/frontend/models/personalBestClass.ts +++ b/frontend/models/personalBestClass.ts @@ -2,18 +2,12 @@ import type { RecordInfo } from '~/types' import TimeagoColumn from '~/components/table/common/timeagoColumn.vue' export const PersonalBestClass = >{ - type: 'personalBestClass', - pluralType: 'personalBestClasses', + typename: 'personalBestClass', + pluralTypename: 'personalBestClasses', name: 'Personal Best Type', pluralName: 'Personal Best Types', icon: 'mdi-content-copy', renderItem: (item) => item.name, - options: { - sortBy: ['created_at'], - sortDesc: [true], - }, - hasSearch: true, - filters: [], fields: { id: { text: 'ID', @@ -39,6 +33,39 @@ export const PersonalBestClass = >{ component: TimeagoColumn, }, }, + + paginationOptions: { + sortOptions: { + sortBy: ['created_at'], + sortDesc: [true], + }, + hasSearch: true, + filters: [], + headers: [ + { + field: 'name', + sortable: true, + }, + { + field: 'set_size', + sortable: false, + width: '100px', + }, + + { + field: 'created_at', + width: '150px', + sortable: true, + }, + { + field: 'updated_at', + width: '150px', + sortable: true, + }, + ], + downloadOptions: {}, + }, + addOptions: { fields: ['name', 'description', 'set_size'], }, @@ -50,27 +77,6 @@ export const PersonalBestClass = >{ }, deleteOptions: {}, shareOptions: undefined, - headers: [ - { - field: 'name', - sortable: true, - }, - { - field: 'set_size', - sortable: false, - width: '100px', - }, - { - field: 'created_at', - width: '150px', - sortable: true, - }, - { - field: 'updated_at', - width: '150px', - sortable: true, - }, - ], expandTypes: [], } diff --git a/frontend/models/product.ts b/frontend/models/product.ts index 0f99521..d4519f0 100644 --- a/frontend/models/product.ts +++ b/frontend/models/product.ts @@ -2,18 +2,12 @@ import type { RecordInfo } from '~/types' import TimeagoColumn from '~/components/table/common/timeagoColumn.vue' export const Product = >{ - type: 'product', - pluralType: 'products', + typename: 'product', + pluralTypename: 'products', name: 'Product', pluralName: 'Products', icon: 'mdi-dots-grid', renderItem: (item) => item.name, - options: { - sortBy: ['created_at'], - sortDesc: [true], - }, - hasSearch: true, - filters: [], fields: { id: { text: 'ID', @@ -30,6 +24,32 @@ export const Product = >{ component: TimeagoColumn, }, }, + paginationOptions: { + sortOptions: { + sortBy: ['created_at'], + sortDesc: [true], + }, + hasSearch: true, + filters: [], + headers: [ + { + field: 'name', + sortable: true, + }, + { + field: 'created_at', + width: '150px', + sortable: true, + }, + { + field: 'updated_at', + width: '150px', + sortable: true, + }, + ], + downloadOptions: {}, + }, + addOptions: { fields: ['name'], }, @@ -41,21 +61,6 @@ export const Product = >{ }, deleteOptions: {}, shareOptions: undefined, - headers: [ - { - field: 'name', - sortable: true, - }, - { - field: 'created_at', - width: '150px', - sortable: true, - }, - { - field: 'updated_at', - width: '150px', - sortable: true, - }, - ], + expandTypes: [], } diff --git a/frontend/models/special/myPbs.ts b/frontend/models/special/myPbs.ts index 183f4d9..ff65d96 100644 --- a/frontend/models/special/myPbs.ts +++ b/frontend/models/special/myPbs.ts @@ -3,4 +3,8 @@ import { PersonalBest } from '..' export const MyPbs = { ...PersonalBest, viewRecordRoute: '/pb', + paginationOptions: { + ...(!!PersonalBest.paginationOptions && PersonalBest.paginationOptions), + downloadOptions: undefined, + }, } diff --git a/frontend/models/special/pbPublic.ts b/frontend/models/special/pbPublic.ts index 4c1d2da..ff11193 100644 --- a/frontend/models/special/pbPublic.ts +++ b/frontend/models/special/pbPublic.ts @@ -2,6 +2,10 @@ import { PersonalBest } from '..' export const PbPublic = { ...PersonalBest, + paginationOptions: { + ...(!!PersonalBest.paginationOptions && PersonalBest.paginationOptions), + downloadOptions: undefined, + }, viewRecordRoute: '/pb', deleteOptions: undefined, addOptions: undefined, diff --git a/frontend/models/special/userPublic.ts b/frontend/models/special/userPublic.ts index 2707914..d0544e8 100644 --- a/frontend/models/special/userPublic.ts +++ b/frontend/models/special/userPublic.ts @@ -1,33 +1,40 @@ -import { User, PersonalBest } from '..' +import { User } from '..' +import { PbPublic } from '.' export const UserPublic = { ...User, viewRecordRoute: '/user', - filters: [], + editOptions: undefined, + paginationOptions: { + ...(!!User.paginationOptions && User.paginationOptions), + filters: [], + headers: [ + { + field: 'name+avatar', + sortable: false, + }, + { + field: 'wca_id', + width: '150px', + sortable: false, + }, + { + field: 'country', + width: '100px', + sortable: false, + }, + ], + downloadOptions: undefined, + }, viewOptions: { fields: ['avatar', 'name', 'email', 'wca_id', 'country'], }, deleteOptions: undefined, - headers: [ - { - field: 'name+avatar', - sortable: false, - }, - { - field: 'wca_id', - width: '150px', - sortable: false, - }, - { - field: 'country', - width: '100px', - sortable: false, - }, - ], + expandTypes: [ { - recordInfo: PersonalBest, + recordInfo: PbPublic, name: 'PBs', excludeFilters: ['created_by.id'], excludeHeaders: ['created_by.name+created_by.avatar'], diff --git a/frontend/models/user.ts b/frontend/models/user.ts index 83d80c2..7ac0fc0 100644 --- a/frontend/models/user.ts +++ b/frontend/models/user.ts @@ -5,27 +5,12 @@ import UserColumn from '~/components/table/common/userColumn.vue' import { getBooleanOptions, getUserRoles } from '~/services/dropdown' export const User = >{ - type: 'user', - pluralType: 'users', + typename: 'user', + pluralTypename: 'users', name: 'User', pluralName: 'Users', icon: 'mdi-account', renderItem: (item) => item.email, - options: { - sortBy: ['created_at'], - sortDesc: [true], - }, - hasSearch: true, - filters: [ - { - field: 'role', - operator: 'eq', - }, - { - field: 'is_public', - operator: 'eq', - }, - ], fields: { id: { text: 'ID', @@ -57,10 +42,17 @@ export const User = >{ getOptions: getUserRoles, inputType: 'select', }, + permissions: { + text: 'Permissions', + serialize: (val: string[]) => val && val.join(','), + parseValue: (val: string) => + val ? val.split(',').filter((ele) => ele) : [], + }, is_public: { text: 'Is Public', getOptions: getBooleanOptions, - parseValue: (val) => (typeof val === 'boolean' ? val : val === 'true'), + // parseValue: (val) => (typeof val === 'boolean' ? val : val === 'true'), + parseQueryValue: (val) => val === 'true', inputType: 'select', }, created_at: { @@ -72,15 +64,60 @@ export const User = >{ component: TimeagoColumn, }, }, + paginationOptions: { + sortOptions: { + sortBy: ['created_at'], + sortDesc: [true], + }, + hasSearch: true, + filters: [ + { + field: 'role', + operator: 'eq', + }, + { + field: 'is_public', + operator: 'eq', + }, + ], + headers: [ + { + field: 'name+avatar', + sortable: false, + }, + { + field: 'email', + sortable: false, + width: '150px', + }, + { + field: 'role', + sortable: true, + width: '150px', + }, + { + field: 'created_at', + width: '150px', + sortable: true, + }, + { + field: 'updated_at', + width: '150px', + sortable: true, + }, + ], + downloadOptions: {}, + }, + addOptions: undefined, editOptions: { fields: [ 'avatar', 'name', 'email', - 'wca_id', 'country', 'role', + 'permissions', 'is_public', ], }, @@ -92,6 +129,7 @@ export const User = >{ 'wca_id', 'country', 'role', + 'permissions', 'is_public', ], }, @@ -100,32 +138,7 @@ export const User = >{ route: '/user', }, enterOptions: {}, - headers: [ - { - field: 'name+avatar', - sortable: false, - }, - { - field: 'email', - sortable: false, - width: '150px', - }, - { - field: 'role', - sortable: true, - width: '150px', - }, - { - field: 'created_at', - width: '150px', - sortable: true, - }, - { - field: 'updated_at', - width: '150px', - sortable: true, - }, - ], + expandTypes: [ { recordInfo: PersonalBest, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6305492..3eead83 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3841,6 +3841,11 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "convert-array-to-csv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-array-to-csv/-/convert-array-to-csv-2.0.0.tgz", + "integrity": "sha512-dxUINCt28k6WbXGMoB+AaKjGY0Y6GkKwZmT+kvD4nJgVCOKsnIQ3G6n0v2II1lG4NwXQk6EWZ+pPDub9wcqqMg==" + }, "convert-source-map": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b8276ee..1a07a42 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dependencies": { "@nuxt/typescript-runtime": "^2.0.0", "axios": "^0.21.0", + "convert-array-to-csv": "^2.0.0", "core-js": "^3.6.5", "js-cookie": "^2.2.1", "nuxt": "^2.14.6", diff --git a/frontend/pages/settings.vue b/frontend/pages/settings.vue index 9ebb213..407a567 100644 --- a/frontend/pages/settings.vue +++ b/frontend/pages/settings.vue @@ -94,8 +94,8 @@