diff --git a/jsapp/js/actions.d.ts b/jsapp/js/actions.d.ts index d26b0aa505..ee29ef1d33 100644 --- a/jsapp/js/actions.d.ts +++ b/jsapp/js/actions.d.ts @@ -194,6 +194,31 @@ interface ReportsSetCustomCompletedDefinition extends Function { listen: (callback: (response: AssetResponse, crid: string) => void) => Function; } +interface HooksGetLogsDefinition extends Function { + ( + assetUid: string, + hookUid: string, + options: { + onComplete: (data: PaginatedResponse) => void; + onFail: () => void; + } + ): void; + listen: (callback: ( + assetUid: string, + hookUid: string, + options: { + onComplete: (data: PaginatedResponse) => void; + onFail: () => void; + } + ) => void) => Function; + completed: HooksGetLogsCompletedDefinition; + failed: GenericFailedDefinition; +} +interface HooksGetLogsCompletedDefinition extends Function { + (response: PaginatedResponse): void; + listen: (callback: (response: PaginatedResponse) => void) => Function; +} + // NOTE: as you use more actions in your ts files, please extend this namespace, // for now we are defining only the ones we need. export namespace actions { @@ -226,7 +251,15 @@ export namespace actions { refreshTableSubmissions: GenericDefinition; getAssetFiles: GenericDefinition; }; - const hooks: object; + const hooks: { + add: GenericDefinition; + update: GenericDefinition; + delete: GenericDefinition; + getAll: GenericDefinition; + getLogs: HooksGetLogsDefinition; + retryLog: GenericDefinition; + retryLogs: GenericDefinition; + }; const misc: { getUser: GetUserDefinition; }; diff --git a/jsapp/js/components/RESTServices/RESTServiceLogs.es6 b/jsapp/js/components/RESTServices/RESTServiceLogs.tsx similarity index 73% rename from jsapp/js/components/RESTServices/RESTServiceLogs.es6 rename to jsapp/js/components/RESTServices/RESTServiceLogs.tsx index 652854663d..9e6e637d36 100644 --- a/jsapp/js/components/RESTServices/RESTServiceLogs.es6 +++ b/jsapp/js/components/RESTServices/RESTServiceLogs.tsx @@ -1,23 +1,52 @@ +// Libraries import React from 'react'; -import autoBind from 'react-autobind'; -import reactMixin from 'react-mixin'; -import Reflux from 'reflux'; import alertify from 'alertifyjs'; -import pageState from 'js/pageState.store'; import bem from 'js/bem'; + +// Partial components import LoadingSpinner from 'js/components/common/loadingSpinner'; -import {actions} from '../../actions'; -import mixins from '../../mixins'; -import {dataInterface} from '../../dataInterface'; -import {formatTime, notify} from 'utils'; +import Button from 'js/components/common/button'; + +// Stores, hooks and utilities +import pageState from 'js/pageState.store'; +import {actions} from 'js/actions'; +import {dataInterface} from 'js/dataInterface'; +import {formatTime, notify} from 'js/utils'; +import assetStore from 'jsapp/js/assetStore'; +import {getRouteAssetUid} from 'jsapp/js/router/routerUtils'; + +// Constants and types +import type { + FailResponse, + ExternalServiceLogResponse, + PaginatedResponse, + ExternalServiceHookResponse, + RetryExternalServiceLogsResponse, +} from 'js/dataInterface'; import { HOOK_LOG_STATUSES, - MODAL_TYPES -} from '../../constants'; -import Button from 'js/components/common/button'; + MODAL_TYPES, +} from 'js/constants'; + +interface RESTServiceLogsProps { + assetUid: string; + hookUid: string; +} -export default class RESTServiceLogs extends React.Component { - constructor(props){ +interface RESTServiceLogsState { + hookName: string | null; + isHookActive: boolean; + assetUid: string; + hookUid: string; + isLoadingHook: boolean; + isLoadingLogs: boolean; + logs: ExternalServiceLogResponse[]; + totalLogsCount?: number; + nextPageUrl: string | null; +} + +export default class RESTServiceLogs extends React.Component { + constructor(props: RESTServiceLogsProps) { super(props); this.state = { hookName: null, @@ -27,29 +56,23 @@ export default class RESTServiceLogs extends React.Component { isLoadingHook: true, isLoadingLogs: true, logs: [], - nextPageUrl: null + nextPageUrl: null, }; - autoBind(this); } componentDidMount() { - this.listenTo( - actions.hooks.getLogs.completed, - this.onLogsUpdated - ); + actions.hooks.getLogs.completed.listen(this.onLogsUpdated.bind(this)); dataInterface.getHook(this.state.assetUid, this.state.hookUid) - .done((data) => { + .done((data: ExternalServiceHookResponse) => { this.setState({ isLoadingHook: false, hookName: data.name, - isHookActive: data.active + isHookActive: data.active, }); }) .fail(() => { - this.setState({ - isLoadingHook: false - }); + this.setState({isLoadingHook: false}); notify.error(t('Could not load REST Service')); }); @@ -62,15 +85,13 @@ export default class RESTServiceLogs extends React.Component { isLoadingLogs: false, logs: data.results, nextPageUrl: data.next, - totalLogsCount: data.count + totalLogsCount: data.count, }); }, onFail: () => { - this.setState({ - isLoadingLogs: false - }); + this.setState({isLoadingLogs: false}); notify.error(t('Could not load REST Service logs')); - } + }, } ); } @@ -78,14 +99,19 @@ export default class RESTServiceLogs extends React.Component { loadMore() { this.setState({isLoadingLogs: false}); - dataInterface.loadNextPageUrl(this.state.nextPageUrl) + if (this.state.nextPageUrl === null) { + return; + } + + (dataInterface.loadNextPageUrl(this.state.nextPageUrl) as JQuery.jqXHR>) .done((data) => { - const newLogs = [].concat(this.state.logs, data.results); + let newLogs: ExternalServiceLogResponse[] = []; + newLogs = newLogs.concat(this.state.logs, data.results); this.setState({ isLoadingLogs: false, logs: newLogs, nextPageUrl: data.next, - totalLogsCount: data.count + totalLogsCount: data.count, }); }) .fail(() => { @@ -94,12 +120,12 @@ export default class RESTServiceLogs extends React.Component { }); } - onLogsUpdated(data) { + onLogsUpdated(data: PaginatedResponse) { this.setState({logs: data.results}); } retryAll() { - const failedLogUids = []; + const failedLogUids: string[] = []; this.state.logs.forEach((log) => { if (log.status === HOOK_LOG_STATUSES.FAILED) { failedLogUids.push(log.uid); @@ -111,14 +137,14 @@ export default class RESTServiceLogs extends React.Component { this.state.assetUid, this.state.hookUid, { - onComplete: (response) => { + onComplete: (response: RetryExternalServiceLogsResponse) => { this.overrideLogsStatus(response.pending_uids, HOOK_LOG_STATUSES.PENDING); - } + }, } ); } - retryLog(log) { + retryLog(log: ExternalServiceLogResponse) { // make sure to allow only retrying failed logs if (log.status !== HOOK_LOG_STATUSES.FAILED) { return; @@ -130,56 +156,55 @@ export default class RESTServiceLogs extends React.Component { this.state.assetUid, this.state.hookUid, log.uid, { - onFail: (response) => { - if (response.responseJSON && response.responseJSON.detail) { + onFail: (response: FailResponse) => { + if (response.responseJSON?.detail) { this.overrideLogMessage(log.uid, response.responseJSON.detail); } this.overrideLogsStatus([log.uid], HOOK_LOG_STATUSES.FAILED); - } + }, } ); } - overrideLogMessage(logUid, newMessage) { + overrideLogMessage(logUid: string, newMessage: string) { const currentLogs = this.state.logs; currentLogs.forEach((currentLog) => { if (currentLog.uid === logUid) { currentLog.message = newMessage; } }); - this.setState({ - logs: currentLogs - }); + this.setState({logs: currentLogs}); } // useful to mark logs as pending, before BE tells about it // NOTE: logUids is an array - overrideLogsStatus(logUids, newStatus) { + overrideLogsStatus(logUids: string[], newStatus: number) { const currentLogs = this.state.logs; currentLogs.forEach((currentLog) => { if (logUids.includes(currentLog.uid)) { currentLog.status = newStatus; } }); - this.setState({ - logs: currentLogs - }); + this.setState({logs: currentLogs}); } - showLogInfo(log) { - const title = t('Submission Failure Detail (##id##)').replace('##id##', log.submission_id); + showLogInfo(log: ExternalServiceLogResponse) { + const title = t('Submission Failure Detail (##id##)').replace('##id##', String(log.submission_id)); const escapedMessage = $('
').text(log.message).html(); alertify.alert(title, `
${escapedMessage}
`); } - openSubmissionModal(log) { - const currentAsset = this.currentAsset(); - pageState.switchModal({ - type: MODAL_TYPES.SUBMISSION, - sid: log.submission_id, - asset: currentAsset, - ids: [log.submission_id] - }); + openSubmissionModal(log: ExternalServiceLogResponse) { + const currentAssetUid = getRouteAssetUid(); + if (currentAssetUid !== null) { + const currentAsset = assetStore.getAsset(currentAssetUid); + pageState.switchModal({ + type: MODAL_TYPES.SUBMISSION, + sid: log.submission_id, + asset: currentAsset, + ids: [log.submission_id], + }); + } } hasAnyFailedLogs() { @@ -192,7 +217,7 @@ export default class RESTServiceLogs extends React.Component { return hasAny; } - hasInfoToDisplay(log) { + hasInfoToDisplay(log: ExternalServiceLogResponse) { return log.status !== HOOK_LOG_STATUSES.SUCCESS && log.message.length > 0; } @@ -278,9 +303,7 @@ export default class RESTServiceLogs extends React.Component { {this.state.logs.map((log, n) => { - const rowProps = { - key: n - }; + const rowProps: {m?: string; onClick?: () => void} = {}; let statusMod = ''; let statusLabel = ''; if (log.status === HOOK_LOG_STATUSES.SUCCESS) { @@ -294,7 +317,7 @@ export default class RESTServiceLogs extends React.Component { statusLabel = t('Pending'); if (log.tries && log.tries > 1) { - statusLabel = t('Pending (##count##×)').replace('##count##', log.tries); + statusLabel = t('Pending (##count##×)').replace('##count##', String(log.tries)); } } if (log.status === HOOK_LOG_STATUSES.FAILED) { @@ -303,7 +326,7 @@ export default class RESTServiceLogs extends React.Component { } return ( - + {log.submission_id} @@ -359,6 +382,3 @@ export default class RESTServiceLogs extends React.Component { } } } - -reactMixin(RESTServiceLogs.prototype, Reflux.ListenerMixin); -reactMixin(RESTServiceLogs.prototype, mixins.contextRouter); diff --git a/jsapp/js/components/RESTServices.scss b/jsapp/js/components/RESTServices/RESTServices.scss similarity index 100% rename from jsapp/js/components/RESTServices.scss rename to jsapp/js/components/RESTServices/RESTServices.scss diff --git a/jsapp/js/components/RESTServices/RESTServicesForm.es6 b/jsapp/js/components/RESTServices/RESTServicesForm.tsx similarity index 64% rename from jsapp/js/components/RESTServices/RESTServicesForm.es6 rename to jsapp/js/components/RESTServices/RESTServicesForm.tsx index ead92135dc..47f01cf567 100644 --- a/jsapp/js/components/RESTServices/RESTServicesForm.es6 +++ b/jsapp/js/components/RESTServices/RESTServicesForm.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import autoBind from 'react-autobind'; +import clonedeep from 'lodash.clonedeep'; import KoboTagsInput from 'js/components/common/koboTagsInput'; import bem from 'js/bem'; import LoadingSpinner from 'js/components/common/loadingSpinner'; -import {dataInterface} from '../../dataInterface'; -import {actions} from '../../actions'; +import {dataInterface, type FailResponse, type ExternalServiceHookResponse} from 'js/dataInterface'; +import {actions} from 'js/actions'; import WrappedSelect from 'js/components/common/wrappedSelect'; import Checkbox from 'js/components/common/checkbox'; import Radio from 'js/components/common/radio'; @@ -15,70 +15,106 @@ import {notify} from 'js/utils'; import pageState from 'js/pageState.store'; import Button from 'js/components/common/button'; +export enum HookExportTypeName { + json = 'json', + xml = 'xml', +} + const EXPORT_TYPES = { json: { - value: 'json', - label: t('JSON') + value: HookExportTypeName.json, + label: t('JSON'), }, xml: { - value: 'xml', - label: t('XML') - } + value: HookExportTypeName.xml, + label: t('XML'), + }, }; +export enum HookAuthLevelName { + no_auth = 'no_auth', + basic_auth = 'basic_auth', +} + const AUTH_OPTIONS = { no_auth: { - value: 'no_auth', - label: t('No Authorization') + value: HookAuthLevelName.no_auth, + label: t('No Authorization'), }, basic_auth: { - value: 'basic_auth', - label: t('Basic Authorization') - } + value: HookAuthLevelName.basic_auth, + label: t('Basic Authorization'), + }, }; -export default class RESTServicesForm extends React.Component { - constructor(props){ +interface RESTServicesFormProps { + assetUid: string; + hookUid?: string; +} + +interface RESTServicesFormState { + isLoadingHook: boolean; + isSubmitPending: boolean; + assetUid: string; + hookUid?: string; + name: string; + nameError?: string; + endpoint: string; + endpointError?: string; + type: HookExportTypeName; + typeOptions: Array<{value: HookExportTypeName; label: string}>; + isActive: boolean; + emailNotification: boolean; + authLevel: {value: HookAuthLevelName; label: string} | null; + authOptions: Array<{value: HookAuthLevelName; label: string}>; + authUsername: string; + authPassword: string; + subsetFields: string[]; + customHeaders: Array<{name: string; value: string}>; + payloadTemplate: string; + payloadTemplateErrors: string | string[]; +} + +export default class RESTServicesForm extends React.Component { + constructor(props: RESTServicesFormProps){ super(props); this.state = { isLoadingHook: true, isSubmitPending: false, assetUid: props.assetUid, - // will be empty if creating new service hookUid: props.hookUid, name: '', - nameError: null, + nameError: undefined, endpoint: '', - endpointError: null, + endpointError: undefined, type: EXPORT_TYPES.json.value, typeOptions: [ EXPORT_TYPES.json, - EXPORT_TYPES.xml + EXPORT_TYPES.xml, ], isActive: true, emailNotification: true, authLevel: null, authOptions: [ AUTH_OPTIONS.no_auth, - AUTH_OPTIONS.basic_auth + AUTH_OPTIONS.basic_auth, ], authUsername: '', authPassword: '', subsetFields: [], customHeaders: [ - this.getEmptyHeaderRow() + this.getEmptyHeaderRow(), ], payloadTemplate: '', - payloadTemplateErrors: [] + payloadTemplateErrors: [], }; - autoBind(this); } componentDidMount() { if (this.state.hookUid) { dataInterface.getHook(this.state.assetUid, this.state.hookUid) - .done((data) => { - const stateUpdate = { + .done((data: ExternalServiceHookResponse) => { + const stateUpdate: Partial = { isLoadingHook: false, name: data.name, endpoint: data.endpoint, @@ -88,10 +124,10 @@ export default class RESTServicesForm extends React.Component { type: data.export_type, authLevel: AUTH_OPTIONS[data.auth_level] || null, customHeaders: this.headersObjToArr(data.settings.custom_headers), - payloadTemplate: data.payload_template + payloadTemplate: data.payload_template, }; - if (stateUpdate.customHeaders.length === 0) { + if (stateUpdate.customHeaders?.length === 0) { stateUpdate.customHeaders.push(this.getEmptyHeaderRow()); } if (data.settings.username) { @@ -101,7 +137,7 @@ export default class RESTServicesForm extends React.Component { stateUpdate.authPassword = data.settings.password; } - this.setState(stateUpdate); + this.setState(stateUpdate as RESTServicesFormState); }) .fail(() => { this.setState({isSubmitPending: false}); @@ -120,21 +156,21 @@ export default class RESTServicesForm extends React.Component { return {name: '', value: ''}; } - headersObjToArr(headersObj) { - const headersArr = []; - for (let header in headersObj) { - if (headersObj.hasOwnProperty(header)) { + headersObjToArr(headersObj: {[key: string]: string}) { + const headersArr: Array<{name: string; value: string}> = []; + for (const header in headersObj) { + if (Object.prototype.hasOwnProperty.call(headersObj, header)) { headersArr.push({ name: header, - value: headersObj[header] + value: headersObj[header], }); } } return headersArr; } - headersArrToObj(headersArr) { - const headersObj = {}; + headersArrToObj(headersArr: Array<{name: string; value: string}>) { + const headersObj: {[key: string]: string} = {}; for (const header of headersArr) { if (header.name) { headersObj[header.name] = header.value; @@ -147,52 +183,61 @@ export default class RESTServicesForm extends React.Component { * user input handling */ - handleNameChange(newName) { + handleNameChange(newName: string) { this.setState({ name: newName, - nameError: null + nameError: undefined, }); } - handleEndpointChange(newEndpoint) { + handleEndpointChange(newEndpoint: string) { this.setState({ endpoint: newEndpoint, - endpointError: null + endpointError: undefined, }); } - handleAuthTypeChange(evt) {this.setState({authLevel: evt});} + handleAuthTypeChange(evt: unknown) { + const newVal = evt as {value: HookAuthLevelName; label: string}; + this.setState({authLevel: newVal}); + } - handleAuthUsernameChange(newUsername) {this.setState({authUsername: newUsername});} + handleAuthUsernameChange(newUsername: string) { + this.setState({authUsername: newUsername}); + } - handleAuthPasswordChange(newPassword) {this.setState({authPassword: newPassword});} + handleAuthPasswordChange(newPassword: string) { + this.setState({authPassword: newPassword}); + } - handleActiveChange(isChecked) {this.setState({isActive: isChecked});} + handleActiveChange(isChecked: boolean) { + this.setState({isActive: isChecked}); + } - handleEmailNotificationChange(isChecked) { + handleEmailNotificationChange(isChecked: boolean) { this.setState({emailNotification: isChecked}); } - handleTypeRadioChange(value, name) {this.setState({[name]: value});} + handleTypeRadioChange(value: string, name: string) { + this.setState({[name]: value} as unknown as Pick); + } - handleCustomHeaderChange(evt) { - const propName = evt.target.name; - const propValue = evt.target.value; - const index = evt.target.dataset.index; - const newCustomHeaders = this.state.customHeaders; - if (propName === 'headerName') { - newCustomHeaders[index].name = propValue; - } - if (propName === 'headerValue') { - newCustomHeaders[index].value = propValue; - } + handleCustomHeaderNameChange(headerIndex: number, newName: string) { + const newCustomHeaders = clonedeep(this.state.customHeaders); + newCustomHeaders[headerIndex].name = newName; this.setState({customHeaders: newCustomHeaders}); } - handleCustomWrapperChange(newVal) { + handleCustomHeaderValueChange(headerIndex: number, newValue: string) { + const newCustomHeaders = clonedeep(this.state.customHeaders); + newCustomHeaders[headerIndex].value = newValue; + this.setState({customHeaders: newCustomHeaders}); + } + + handleCustomWrapperChange(newVal: string) { this.setState({ payloadTemplate: newVal, - payloadTemplateErrors: [] + payloadTemplateErrors: [], }); } @@ -206,7 +251,7 @@ export default class RESTServicesForm extends React.Component { authLevel = this.state.authLevel.value; } - const data = { + const data: Partial = { name: this.state.name, endpoint: this.state.endpoint, active: this.state.isActive, @@ -215,15 +260,15 @@ export default class RESTServicesForm extends React.Component { export_type: this.state.type, auth_level: authLevel, settings: { - custom_headers: this.headersArrToObj(this.state.customHeaders) + custom_headers: this.headersArrToObj(this.state.customHeaders), }, - payload_template: this.state.payloadTemplate + payload_template: this.state.payloadTemplate, }; - if (this.state.authUsername) { + if (this.state.authUsername && data.settings !== undefined) { data.settings.username = this.state.authUsername; } - if (this.state.authPassword) { + if (this.state.authPassword && data.settings !== undefined) { data.settings.password = this.state.authPassword; } return data; @@ -242,7 +287,7 @@ export default class RESTServicesForm extends React.Component { return isValid; } - onSubmit(evt) { + onSubmit(evt: React.FormEvent) { evt.preventDefault(); if (!this.validateForm()) { @@ -255,18 +300,14 @@ export default class RESTServicesForm extends React.Component { pageState.hideModal(); actions.resources.loadAsset({id: this.state.assetUid}); }, - onFail: (data) => { - let payloadTemplateErrors = []; - if ( - data.responseJSON && - data.responseJSON.payload_template && - data.responseJSON.payload_template.length !== 0 - ) { - payloadTemplateErrors = data.responseJSON.payload_template; + onFail: (data: FailResponse) => { + let payloadTemplateErrors: string | string[] = []; + if (data.responseJSON?.payload_template?.length !== 0) { + payloadTemplateErrors = data.responseJSON?.payload_template || []; } this.setState({ payloadTemplateErrors: payloadTemplateErrors, - isSubmitPending: false + isSubmitPending: false, }); }, }; @@ -293,18 +334,20 @@ export default class RESTServicesForm extends React.Component { * handle custom headers */ - onCustomHeaderInputKeyPress(evt) { - if (evt.keyCode === KEY_CODES.ENTER && evt.currentTarget.name === 'headerName') { - evt.preventDefault(); - $(evt.currentTarget).parent().find('input[name="headerValue"]').focus(); - } - if (evt.keyCode === KEY_CODES.ENTER && evt.currentTarget.name === 'headerValue') { - evt.preventDefault(); - this.addNewCustomHeaderRow(); - } - } - - addNewCustomHeaderRow(evt) { + onCustomHeaderInputKeyPress(evt: React.KeyboardEvent) { + // Pressing ENTER key while editing the name, moves focus to the input for the value + if (evt.keyCode === KEY_CODES.ENTER && evt.currentTarget.name === 'headerName') { + evt.preventDefault(); + (evt.currentTarget.parentElement?.querySelector('input[name="headerValue"]') as HTMLInputElement).focus(); + } + // Pressing ENTER key while editing the value, adds a new row and moves focus to its name input + if (evt.keyCode === KEY_CODES.ENTER && evt.currentTarget.name === 'headerValue') { + evt.preventDefault(); + this.addNewCustomHeaderRow(); + } + } + + addNewCustomHeaderRow(evt?: React.MouseEvent) { if (evt) { evt.preventDefault(); } @@ -312,15 +355,17 @@ export default class RESTServicesForm extends React.Component { newCustomHeaders.push(this.getEmptyHeaderRow()); this.setState({customHeaders: newCustomHeaders}); setTimeout(() => { - $('input[name="headerName"]').last().focus(); + const inputs = document.querySelectorAll('input[name="headerName"]'); + const lastEl = inputs[inputs.length - 1]; + if (lastEl !== null) { + (lastEl as HTMLInputElement).focus(); + } }, 0); } - removeCustomHeaderRow(evt) { - evt.preventDefault(); - const newCustomHeaders = this.state.customHeaders; - const rowIndex = evt.currentTarget.dataset.index; - newCustomHeaders.splice(rowIndex, 1); + removeCustomHeaderRow(headerIndex: number) { + const newCustomHeaders = clonedeep(this.state.customHeaders); + newCustomHeaders.splice(Number(headerIndex), 1); if (newCustomHeaders.length === 0) { newCustomHeaders.push(this.getEmptyHeaderRow()); } @@ -334,10 +379,8 @@ export default class RESTServicesForm extends React.Component { {t('Custom HTTP Headers')} - {this.state.customHeaders.map((item, n) => { - // TODO change these inputs into ``es, make sure that that - // weird onChange handling is turned into something less confusing - return ( + {this.state.customHeaders.map((_item, n) => + ( ) => { + this.handleCustomHeaderNameChange(n, evt.target.value); + }} + onKeyDown={this.onCustomHeaderInputKeyPress.bind(this)} /> ) => { + this.handleCustomHeaderValueChange(n, evt.target.value); + }} + onKeyDown={this.onCustomHeaderInputKeyPress.bind(this)} />