diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 5c3484557d..67c65fc7be 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -2,3 +2,6 @@ # Applied automated eslint autofixes and prettier to map.es6 be3a9b6041e451b02c671220be50b5ca33cae1a8 +# Prettier/ESLint autofixes to reduce merge noise between diverging branches +# (beta after react router 6.14 upgrade; feature/my-projects) +af65c7c161a3e044ec7a5910716edbb09f326462 diff --git a/hub/admin.py b/hub/admin.py index b7f1e185c8..14c6d9db1b 100644 --- a/hub/admin.py +++ b/hub/admin.py @@ -34,6 +34,7 @@ SearchQueryTooShortException, ) from kpi.filters import SearchFilter +from kpi.models.asset import AssetDeploymentStatus from .models import ( ExtraUserDetail, ConfigurationFile, @@ -179,7 +180,7 @@ def deployed_forms_count(self, obj): Django admin user changelist page """ assets_count = obj.assets.filter( - _deployment_data__active=True + _deployment_status=AssetDeploymentStatus.DEPLOYED ).aggregate(count=Count('pk')) return assets_count['count'] diff --git a/jsapp/js/actions.es6 b/jsapp/js/actions.es6 index 1df1bafb87..486cc9807d 100644 --- a/jsapp/js/actions.es6 +++ b/jsapp/js/actions.es6 @@ -263,10 +263,13 @@ actions.resources.deployAsset.failed.listen(function(data, redeployment){ alertify.alert(t('unable to deploy'), failure_message); }); -actions.resources.setDeploymentActive.listen(function(details) { +actions.resources.setDeploymentActive.listen(function(details, onComplete) { dataInterface.setDeploymentActive(details) .done((data) => { actions.resources.setDeploymentActive.completed(data.asset); + if (typeof onComplete === 'function') { + onComplete(data); + } }) .fail(actions.resources.setDeploymentActive.failed); }); diff --git a/jsapp/js/actions/library.es6 b/jsapp/js/actions/library.es6 index 22cf90d112..87219fd5bf 100644 --- a/jsapp/js/actions/library.es6 +++ b/jsapp/js/actions/library.es6 @@ -193,9 +193,9 @@ libraryActions.getCollections.listen((params) => { libraryActions.moveToCollection.completed.listen((asset) => { if (asset.parent === null) { - notify(t('Successfuly removed from collection')); + notify(t('Successfully removed from collection')); } else { - notify(t('Successfuly moved to collection')); + notify(t('Successfully moved to collection')); } }); libraryActions.moveToCollection.failed.listen(() => { diff --git a/jsapp/js/alertify.ts b/jsapp/js/alertify.ts index 0e2390c978..7c2d4896c8 100644 --- a/jsapp/js/alertify.ts +++ b/jsapp/js/alertify.ts @@ -3,6 +3,8 @@ import alertify from 'alertifyjs'; import {KeyNames} from 'js/constants'; import type {IconName} from 'jsapp/fonts/k-icons'; import {escapeHtml} from 'js/utils'; +import type {ReactElement} from 'react'; +import {render} from 'react-dom'; interface MultiConfirmButton { label: string; @@ -184,3 +186,13 @@ export function destroyConfirm( return dialog; } + +/** + * Useful to pass a complex JSX message into alertify (which accepts only + * strings). + */ +export function renderJSXMessage(jsx: ReactElement) { + const domNode = document.createElement('div'); + render(jsx, domNode); + return domNode.outerHTML; +} diff --git a/jsapp/js/api.ts b/jsapp/js/api.ts index 92c9fdd027..4b1653f1b0 100644 --- a/jsapp/js/api.ts +++ b/jsapp/js/api.ts @@ -16,7 +16,17 @@ const JSON_HEADER = 'application/json'; // 500 // half second // ); -const fetchData = async (path: string, method = 'GET', data?: Json) => { +const fetchData = async ( + /** If you have full url to be called, remember to use `prependRootUrl`. */ + path: string, + method = 'GET', + data?: Json, + /** + * Useful if you already have a full URL to be called and there is no point + * adding `ROOT_URL` to it. + */ + prependRootUrl = true +) => { const headers: {[key: string]: string} = { Accept: JSON_HEADER, }; @@ -30,7 +40,9 @@ const fetchData = async (path: string, method = 'GET', data?: Json) => { headers['Content-Type'] = JSON_HEADER; } - const response = await fetch(ROOT_URL + path, { + const url = prependRootUrl ? ROOT_URL + path : path; + + const response = await fetch(url, { method: method, headers, body: JSON.stringify(data), @@ -92,6 +104,10 @@ export const fetchGet = async (path: string) => fetchData(path); export const fetchPost = async (path: string, data: Json) => fetchData(path, 'POST', data); +/** POST data to Kobo API at url */ +export const fetchPostUrl = async (url: string, data: Json) => + fetchData(url, 'POST', data, false); + /** PATCH (update) data to Kobo API at path */ export const fetchPatch = async (path: string, data: Json) => fetchData(path, 'PATCH', data); diff --git a/jsapp/js/app.js b/jsapp/js/app.js index a702a72041..fe13a8bd1f 100644 --- a/jsapp/js/app.js +++ b/jsapp/js/app.js @@ -13,9 +13,10 @@ import 'js/surveyCompanionStore'; // importing it so it exists import {} from 'js/bemComponents'; // importing it so it exists import bem from 'js/bem'; import mixins from 'js/mixins'; -import MainHeader from 'js/components/header'; +import MainHeader from 'js/components/header/mainHeader.component'; import Drawer from 'js/components/drawer'; -import FormViewTabs from 'js/components/formViewTabs'; +import FormViewSideTabs from 'js/components/formViewSideTabs'; +import ProjectTopTabs from 'js/project/projectTopTabs.component'; import PermValidator from 'js/components/permissions/permValidator'; import {assign} from 'utils'; import BigModal from 'js/components/bigModal/bigModal'; @@ -86,7 +87,7 @@ class App extends React.Component { {!this.isFormBuilder() && ( - + )} @@ -97,8 +98,8 @@ class App extends React.Component { > {!this.isFormBuilder() && ( - - + {this.isFormSingle() && } + )} diff --git a/jsapp/js/assetQuickActions.tsx b/jsapp/js/assetQuickActions.tsx new file mode 100644 index 0000000000..e6aeb8b0a5 --- /dev/null +++ b/jsapp/js/assetQuickActions.tsx @@ -0,0 +1,584 @@ +/** + * Hi! This file contains different methods for manipulating asset. Most of + * these are meant to change the data on back-end. + * + * The reason for creating this file is to not burden `assetUtils.ts` with more + * lines of code and to kill `mixins.tsx` as soon as possible (aiming at first + * quarter of 2024 AKA The Far Future With Flying Cars :fingers_crossed:). + */ + +import React from 'react'; +import escape from 'lodash.escape'; +import alertify from 'alertifyjs'; +import {stores} from './stores'; +import sessionStore from 'js/stores/session'; +import {actions} from './actions'; +import type { + AssetResponse, + Permission, + ProjectViewAsset, + DeploymentResponse, +} from './dataInterface'; +import {router, routerIsActive} from './router/legacy'; +import {ROUTES} from './router/routerConstants'; +import {ASSET_TYPES, MODAL_TYPES, PERMISSIONS_CODENAMES} from './constants'; +import {notify, renderCheckbox} from './utils'; +import assetUtils from './assetUtils'; +import myLibraryStore from './components/library/myLibraryStore'; +import permConfig from './components/permissions/permConfig'; +import toast from 'react-hot-toast'; +import {userCan} from './components/permissions/utils'; +import {renderJSXMessage} from './alertify'; + +export function openInFormBuilder(uid: string) { + if (routerIsActive('library')) { + router!.navigate(ROUTES.EDIT_LIBRARY_ITEM.replace(':uid', uid)); + } else { + router!.navigate(ROUTES.FORM_EDIT.replace(':uid', uid)); + } +} + +export function deleteAsset( + assetOrUid: AssetResponse | ProjectViewAsset | string, + name: string, + callback?: (deletedAssetUid: string) => void +) { + let asset: AssetResponse | ProjectViewAsset; + if (typeof assetOrUid === 'object') { + asset = assetOrUid; + } else { + asset = stores.allAssets.byUid[assetOrUid]; + } + const assetTypeLabel = ASSET_TYPES[asset.asset_type].label; + + const safeName = escape(name); + + const dialog = alertify.dialog('confirm'); + const deployed = asset.has_deployment; + let msg; + let onshow; + const onok = () => { + actions.resources.deleteAsset( + {uid: asset.uid, assetType: asset.asset_type}, + { + onComplete: () => { + notify( + t('##ASSET_TYPE## deleted permanently').replace( + '##ASSET_TYPE##', + assetTypeLabel + ) + ); + if (typeof callback === 'function') { + callback(asset.uid); + } + }, + } + ); + }; + + if (!deployed) { + if (asset.asset_type !== ASSET_TYPES.survey.id) { + msg = t( + 'You are about to permanently delete this item from your library.' + ); + } else { + msg = t('You are about to permanently delete this draft.'); + } + } else { + msg = `${t('You are about to permanently delete this form.')}`; + if (asset.deployment__submission_count !== 0) { + msg += `${renderCheckbox( + 'dt1', + t('All data gathered for this form will be deleted.') + )}`; + } + msg += `${renderCheckbox( + 'dt2', + t('The form associated with this project will be deleted.') + )}`; + msg += `${renderCheckbox( + 'dt3', + t( + 'I understand that if I delete this project I will not be able to recover it.' + ), + true + )}`; + + onshow = () => { + const okBtn = dialog.elements.buttons.primary.firstChild as HTMLElement; + okBtn.setAttribute('data-cy', 'delete'); + const $els = $('.alertify-toggle input'); + + okBtn.setAttribute('disabled', 'true'); + $els.each(function () { + $(this).prop('checked', false); + }); + + $els.change(function () { + okBtn.removeAttribute('disabled'); + $els.each(function () { + if (!$(this).prop('checked')) { + okBtn.setAttribute('disabled', 'true'); + } + }); + }); + }; + } + const opts = { + title: `${t('Delete')} ${assetTypeLabel} "${safeName}"`, + message: msg, + labels: { + ok: t('Delete'), + cancel: t('Cancel'), + }, + onshow: onshow, + onok: onok, + oncancel: () => { + dialog.destroy(); + $('.alertify-toggle input').prop('checked', false); + }, + }; + dialog.set(opts).show(); +} + +/** Displays a confirmation popup before archiving. */ +export function archiveAsset( + assetOrUid: AssetResponse | ProjectViewAsset | string, + callback?: (response: DeploymentResponse) => void +) { + let asset: AssetResponse | ProjectViewAsset; + if (typeof assetOrUid === 'object') { + asset = assetOrUid; + } else { + asset = stores.allAssets.byUid[assetOrUid]; + } + // TODO: stop using alertify here, use KoboPrompt + const dialog = alertify.dialog('confirm'); + const opts = { + title: t('Archive Project'), + message: `${t('Are you sure you want to archive this project?')}

+ ${t( + 'Your form will not accept submissions while it is archived.' + )}`, + labels: {ok: t('Archive'), cancel: t('Cancel')}, + onok: () => { + actions.resources.setDeploymentActive( + { + asset: asset, + active: false, + }, + (response: DeploymentResponse) => { + if (typeof callback === 'function') { + callback(response); + } + dialog.destroy(); + } + ); + }, + oncancel: () => { + dialog.destroy(); + }, + }; + dialog.set(opts).show(); +} + +/** Displays a confirmation popup before unarchiving. */ +export function unarchiveAsset( + assetOrUid: AssetResponse | ProjectViewAsset | string, + callback?: (response: DeploymentResponse) => void +) { + let asset: AssetResponse | ProjectViewAsset; + if (typeof assetOrUid === 'object') { + asset = assetOrUid; + } else { + asset = stores.allAssets.byUid[assetOrUid]; + } + // TODO: stop using alertify here, use KoboPrompt + const dialog = alertify.dialog('confirm'); + const opts = { + title: t('Unarchive Project'), + message: `${t('Are you sure you want to unarchive this project?')}`, + labels: {ok: t('Unarchive'), cancel: t('Cancel')}, + onok: () => { + actions.resources.setDeploymentActive( + { + asset: asset, + active: true, + }, + (response: DeploymentResponse) => { + if (typeof callback === 'function') { + callback(response); + } + dialog.destroy(); + } + ); + }, + oncancel: () => { + dialog.destroy(); + }, + }; + dialog.set(opts).show(); +} + +/** Creates a duplicate of an asset. */ +export function cloneAsset( + assetOrUid: AssetResponse | ProjectViewAsset | string +) { + let asset: AssetResponse | ProjectViewAsset; + if (typeof assetOrUid === 'object') { + asset = assetOrUid; + } else { + asset = stores.allAssets.byUid[assetOrUid]; + } + const assetTypeLabel = ASSET_TYPES[asset.asset_type].label; + + let newName; + const displayName = assetUtils.getAssetDisplayName(asset); + // propose new name only if source asset name is not empty + if (displayName.original) { + newName = `${t('Clone of')} ${displayName.original}`; + } + + const dialog = alertify.dialog('prompt'); + const okBtn = dialog.elements.buttons.primary.firstChild as HTMLElement; + const opts = { + title: `${t('Clone')} ${assetTypeLabel}`, + message: t( + 'Enter the name of the cloned ##ASSET_TYPE##. Leave empty to keep the original name.' + ).replace('##ASSET_TYPE##', assetTypeLabel), + value: newName, + labels: {ok: t('Ok'), cancel: t('Cancel')}, + onok: ({}, value: string) => { + okBtn.setAttribute('disabled', 'true'); + okBtn.innerText = t('Cloning...'); + + let parent; + if ('parent' in asset && asset.parent) { + const foundParentAsset = myLibraryStore.findAssetByUrl(asset.parent); + const canAddToParent = + typeof foundParentAsset !== 'undefined' && + userCan(PERMISSIONS_CODENAMES.change_asset, foundParentAsset); + if (canAddToParent) { + parent = asset.parent; + } + } + + actions.resources.cloneAsset( + { + uid: asset.uid, + name: value, + parent: parent, + }, + { + onComplete: (newAsset: AssetResponse) => { + okBtn.removeAttribute('disabled'); + dialog.destroy(); + + // TODO when on collection landing page and user clones this + // collection's child asset, instead of navigating to cloned asset + // landing page, it would be better to stay here and refresh data + // (if the clone will keep the parent asset) + let goToUrl; + if (newAsset.asset_type === ASSET_TYPES.survey.id) { + goToUrl = `/forms/${newAsset.uid}/landing`; + } else { + goToUrl = `/library/asset/${newAsset.uid}`; + } + + router!.navigate(goToUrl); + notify( + t('cloned ##ASSET_TYPE## created').replace( + '##ASSET_TYPE##', + assetTypeLabel + ) + ); + }, + } + ); + // keep the dialog open + return false; + }, + oncancel: () => { + dialog.destroy(); + }, + }; + dialog.set(opts).show(); +} + +/** + * Creates a new asset with provided type. Please use shortcut methods defined + * below. + */ +function _cloneAssetAsNewType(params: { + sourceUid: string; + sourceName: string; + targetType: string; + promptTitle: string; + promptMessage: string; +}) { + // TODO: stop using alertify here, use KoboPrompt + const dialog = alertify.dialog('prompt'); + const opts = { + title: params.promptTitle, + message: params.promptMessage, + value: escape(params.sourceName), + labels: {ok: t('Create'), cancel: t('Cancel')}, + onok: ({}, value: string) => { + // disable buttons + // NOTE: we need to cast it as HTMLElement because of missing innerText in declaration. + const button1 = dialog.elements.buttons.primary + .children[0] as HTMLElement; + button1.setAttribute('disabled', 'true'); + button1.innerText = t('Please wait…'); + dialog.elements.buttons.primary.children[1].setAttribute( + 'disabled', + 'true' + ); + + actions.resources.cloneAsset( + { + uid: params.sourceUid, + name: value, + new_asset_type: params.targetType, + }, + { + onComplete: (asset: AssetResponse) => { + dialog.destroy(); + + switch (asset.asset_type) { + case ASSET_TYPES.survey.id: + router!.navigate( + ROUTES.FORM_LANDING.replace(':uid', asset.uid) + ); + break; + case ASSET_TYPES.template.id: + case ASSET_TYPES.block.id: + case ASSET_TYPES.question.id: + router!.navigate(ROUTES.LIBRARY); + break; + } + }, + onFailed: () => { + dialog.destroy(); + notify.error(t('Failed to create new asset!')); + }, + } + ); + + // keep the dialog open + return false; + }, + oncancel: () => { + dialog.destroy(); + }, + }; + dialog.set(opts).show(); +} + +/** To be used when creating a template from existing project. */ +export function cloneAssetAsTemplate(sourceUid: string, sourceName: string) { + _cloneAssetAsNewType({ + sourceUid: sourceUid, + sourceName: sourceName, + targetType: ASSET_TYPES.template.id, + promptTitle: t('Create new template from this project'), + promptMessage: t('Enter the name of the new template.'), + }); +} + +/** To be used when creating a project from template. */ +export function cloneAssetAsSurvey(sourceUid: string, sourceName: string) { + _cloneAssetAsNewType({ + sourceUid: sourceUid, + sourceName: sourceName, + targetType: ASSET_TYPES.survey.id, + promptTitle: t('Create new project from this template'), + promptMessage: t('Enter the name of the new project.'), + }); +} + +export function removeAssetSharing(uid: string) { + /** + * Extends `removeAllPermissions` from `userPermissionRow.es6`: + * Checks for permissions from current user before finding correct + * "most basic" permission to remove. + */ + const asset = stores.allAssets.byUid[uid]; + const userViewAssetPerm = asset.permissions.find((perm: Permission) => { + // Get permissions url related to current user + const permUserUrl = perm.user.split('/'); + return ( + permUserUrl[permUserUrl.length - 2] === + sessionStore.currentAccount.username && + perm.permission === + permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.view_asset) + ?.url + ); + }); + + const dialog = alertify.dialog('confirm'); + const opts = { + title: t('Remove shared form'), + message: `${t('Are you sure you want to remove this shared form?')}`, + labels: {ok: t('Remove'), cancel: t('Cancel')}, + onok: () => { + // Only non-owners should have the asset removed from their asset list. + // This menu option is only open to non-owners so we don't need to check again. + const isNonOwner = true; + actions.permissions.removeAssetPermission( + uid, + userViewAssetPerm.url, + isNonOwner + ); + }, + oncancel: () => { + dialog.destroy(); + }, + }; + dialog.set(opts).show(); +} + +function _deployAssetFirstTime( + asset: AssetResponse | ProjectViewAsset, + callback?: (response: DeploymentResponse) => void +) { + const deploymentToast = notify.warning(t('deploying to kobocat...'), { + duration: 60 * 1000, + }); + actions.resources.deployAsset(asset, false, { + onDone: (response: DeploymentResponse) => { + notify(t('deployed form')); + actions.resources.loadAsset({id: asset.uid}); + router!.navigate(`/forms/${asset.uid}`); + toast.dismiss(deploymentToast); + if (typeof callback === 'function') { + callback(response); + } + }, + onFail: () => { + toast.dismiss(deploymentToast); + }, + }); +} + +function _redeployAsset( + asset: AssetResponse | ProjectViewAsset, + callback?: (response: DeploymentResponse) => void +) { + const dialog = alertify.dialog('confirm'); + const opts = { + title: t('Overwrite existing deployment'), + // We wrap the JSX code in curly braces inside of backticks to make a string + // out of it (alertify requires a string). + message: renderJSXMessage( + + {t( + 'This form has already been deployed. Are you sure you want overwrite the existing deployment?' + )} +
+
+ {t('This action cannot be undone.')} +
+ ), + labels: {ok: t('Ok'), cancel: t('Cancel')}, + onok: () => { + const okBtn = dialog.elements.buttons.primary.firstChild as HTMLElement; + okBtn.setAttribute('disabled', 'true'); + okBtn.innerText = t('Deploying...'); + actions.resources.deployAsset(asset, true, { + onDone: (response: DeploymentResponse) => { + notify(t('redeployed form')); + // TODO: this ensures that after deploying an asset, we get the fresh + // data for it. But this also causes duplicated calls in some cases. + // It needs some investigation. + actions.resources.loadAsset({id: asset.uid}); + if (dialog && typeof dialog.destroy === 'function') { + dialog.destroy(); + } + if (typeof callback === 'function') { + callback(response); + } + }, + onFail: () => { + if (dialog && typeof dialog.destroy === 'function') { + dialog.destroy(); + } + }, + }); + // keep the dialog open + return false; + }, + oncancel: () => { + dialog.destroy(); + }, + }; + dialog.set(opts).show(); +} + +export function deployAsset( + asset: AssetResponse | ProjectViewAsset, + callback?: (response: DeploymentResponse) => void +) { + if (!asset || asset.asset_type !== ASSET_TYPES.survey.id) { + console.error('Asset not supplied or not of type "survey".'); + return; + } + if (!asset.has_deployment) { + _deployAssetFirstTime(asset, callback); + } else { + _redeployAsset(asset, callback); + } +} + +/** Opens a modal for sharing asset. */ +export function manageAssetSharing(uid: string) { + stores.pageState.showModal({type: MODAL_TYPES.SHARING, uid: uid}); +} + +/** Opens a modal for replacing an asset using a file. */ +export function replaceAssetForm(asset: AssetResponse | ProjectViewAsset) { + stores.pageState.showModal({type: MODAL_TYPES.REPLACE_PROJECT, asset: asset}); +} + +/** + * Opens a modal for modifying asset languages and translation strings. It can + * receive `uid` and will fetch all data by itself, or be given all the data + * up front via `asset` parameter. + */ +export function manageAssetLanguages(uid: string, asset?: AssetResponse) { + stores.pageState.showModal({ + type: MODAL_TYPES.FORM_LANGUAGES, + assetUid: uid, + asset: asset, + }); +} + +export function manageAssetEncryption(uid: string) { + stores.pageState.showModal({type: MODAL_TYPES.ENCRYPT_FORM, assetUid: uid}); +} + +/** Opens a modal for modifying asset tags (also editable in Details Modal). */ +export function modifyAssetTags(asset: AssetResponse | ProjectViewAsset) { + stores.pageState.showModal({type: MODAL_TYPES.ASSET_TAGS, asset: asset}); +} + +/** + * Opens a modal for editing asset details. Currently handles only two types: + * `template` and `collection`. + */ +export function manageAssetSettings(asset: AssetResponse) { + let modalType; + if (asset.asset_type === ASSET_TYPES.template.id) { + modalType = MODAL_TYPES.LIBRARY_TEMPLATE; + } else if (asset.asset_type === ASSET_TYPES.collection.id) { + modalType = MODAL_TYPES.LIBRARY_COLLECTION; + } + if (modalType) { + stores.pageState.showModal({ + type: modalType, + asset: asset, + }); + } else { + throw new Error(`Unsupported asset type: ${asset.asset_type}.`); + } +} diff --git a/jsapp/js/assetStore.ts b/jsapp/js/assetStore.ts index 0780556779..42c5e45e4d 100644 --- a/jsapp/js/assetStore.ts +++ b/jsapp/js/assetStore.ts @@ -39,8 +39,8 @@ class AssetStore extends Reflux.Store { this.trigger(this.data); } - /** Returns asset data (if exists). */ - getAsset(assetUid: string) { + /** Returns asset object (if exists). */ + getAsset(assetUid: string): AssetResponse | undefined { return this.data[assetUid]; } diff --git a/jsapp/js/assetUtils.ts b/jsapp/js/assetUtils.ts index 81f64193b1..c9b64980ad 100644 --- a/jsapp/js/assetUtils.ts +++ b/jsapp/js/assetUtils.ts @@ -1,3 +1,8 @@ +/** + * This file contains different methods for filtering and understanding asset's + * data. Most of these are helpers for rendering information in UI. + */ + import React from 'react'; import {stores} from 'js/stores'; import permConfig from 'js/components/permissions/permConfig'; @@ -38,6 +43,7 @@ import { getSupplementalTranslationPath, } from 'js/components/processing/processingUtils'; import type {LanguageCode} from 'js/components/languages/languagesStore'; +import type {IconName} from 'jsapp/fonts/k-icons'; /** * Removes whitespace from tags. Returns list of cleaned up tags. @@ -63,7 +69,7 @@ export function getAssetOwnerDisplayName(username: string) { } } -export function getOrganizationDisplayString(asset: AssetResponse) { +export function getOrganizationDisplayString(asset: AssetResponse | ProjectViewAsset) { if (asset.settings.organization) { return asset.settings.organization; } else { @@ -150,6 +156,11 @@ export function getCountryDisplayString(asset: AssetResponse | ProjectViewAsset) } else { countries.push(envStore.getCountryLabel(asset.settings.country.value)); } + + if (countries.length === 0) { + return '-'; + } + // TODO: improve for RTL? // See: https://github.com/kobotoolbox/kpi/issues/3903 return countries.join(', '); @@ -240,9 +251,9 @@ export function isLibraryAsset(assetType: AssetTypeName) { * Checks whether the asset is public - i.e. visible and discoverable by anyone. * Note that `view_asset` is implied when you have `discover_asset`. */ -export function isAssetPublic(permissions: Permission[]) { +export function isAssetPublic(permissions?: Permission[]) { let isDiscoverableByAnonymous = false; - permissions.forEach((perm) => { + permissions?.forEach((perm) => { const foundPerm = permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.discover_asset); if ( perm.user === buildUserUrl(ANON_USERNAME) && @@ -256,106 +267,46 @@ export function isAssetPublic(permissions: Permission[]) { } /** - * For getting the icon class name for given asset type. Returned string always - * contains two class names: base `k-icon` and respective CSS class name. + * For getting the icon name for given asset type. Recommended to be used with + * the `` component. */ -export function getAssetIcon(asset: AssetResponse) { +export function getAssetIcon(asset: AssetResponse): IconName { switch (asset.asset_type) { case ASSET_TYPES.template.id: - if (asset.summary?.lock_any) { - return 'k-icon k-icon-template-locked'; + if ('summary' in asset && asset.summary?.lock_any) { + return 'template-locked'; } else { - return 'k-icon k-icon-template'; + return 'template'; } case ASSET_TYPES.question.id: - return 'k-icon k-icon-question'; + return 'question'; case ASSET_TYPES.block.id: - return 'k-icon k-icon-block'; + return 'block'; case ASSET_TYPES.survey.id: - if (asset.summary?.lock_any) { - return 'k-icon k-icon-project-locked'; - } else if (asset.has_deployment && !asset.deployment__active) { - return 'k-icon k-icon-project-archived'; - } else if (asset.has_deployment) { - return 'k-icon k-icon-project-deployed'; + if ('summary' in asset && asset.summary?.lock_any) { + return 'project-locked'; + } else if (asset.deployment_status === 'archived') { + return 'project-archived'; + } else if (asset.deployment_status === 'deployed') { + return 'project-deployed'; } else { - return 'k-icon k-icon-project-draft'; + return 'project-draft'; } case ASSET_TYPES.collection.id: - if (asset?.access_types?.includes(ACCESS_TYPES.subscribed)) { - return 'k-icon k-icon-folder-subscribed'; + if ('access_types' in asset && asset?.access_types?.includes(ACCESS_TYPES.subscribed)) { + return 'folder-subscribed'; } else if (isAssetPublic(asset.permissions)) { - return 'k-icon k-icon-folder-public'; + return 'folder-public'; } else if (asset?.access_types?.includes(ACCESS_TYPES.shared)) { - return 'k-icon k-icon-folder-shared'; + return 'folder-shared'; } else { - return 'k-icon k-icon-folder'; + return 'folder'; } default: - return 'k-icon k-icon-project'; + return 'project'; } } -/** - * Opens a modal for editing asset details. - */ -export function modifyDetails(asset: AssetResponse) { - let modalType; - if (asset.asset_type === ASSET_TYPES.template.id) { - modalType = MODAL_TYPES.LIBRARY_TEMPLATE; - } else if (asset.asset_type === ASSET_TYPES.collection.id) { - modalType = MODAL_TYPES.LIBRARY_COLLECTION; - } - if (modalType) { - stores.pageState.showModal({ - type: modalType, - asset: asset, - }); - } else { - throw new Error(`Unsupported asset type: ${asset.asset_type}.`); - } -} - -/** - * Opens a modal for sharing asset. - */ -export function share(asset: AssetResponse) { - stores.pageState.showModal({ - type: MODAL_TYPES.SHARING, - assetid: asset.uid, - }); -} - -/** - * Opens a modal for modifying asset languages and translation strings. - */ -export function editLanguages(asset: AssetResponse) { - stores.pageState.showModal({ - type: MODAL_TYPES.FORM_LANGUAGES, - asset: asset, - }); -} - -/** - * Opens a modal for modifying asset tags (also editable in Details Modal). - */ -export function editTags(asset: AssetResponse) { - stores.pageState.showModal({ - type: MODAL_TYPES.ASSET_TAGS, - asset: asset, - }); -} - -/** - * Opens a modal for replacing an asset using a file. - */ -export function replaceForm(asset: AssetResponse) { - stores.pageState.showModal({ - type: MODAL_TYPES.REPLACE_PROJECT, - asset: asset, - }); -} - export type SurveyFlatPaths = { [P in string]: string }; @@ -807,8 +758,6 @@ export function isAssetProcessingActivated(assetUid: string) { export default { buildAssetUrl, cleanupTags, - editLanguages, - editTags, getAssetDisplayName, getAssetIcon, getAssetOwnerDisplayName, @@ -827,10 +776,7 @@ export default { isLibraryAsset, isRowSpecialLabelHolder, isSelfOwned, - modifyDetails, renderQuestionTypeIcon, - replaceForm, - share, removeInvalidChars, getAssetAdvancedFeatures, getAssetProcessingUrl, diff --git a/jsapp/js/bemComponents.ts b/jsapp/js/bemComponents.ts index e0da1885a1..68f214b267 100644 --- a/jsapp/js/bemComponents.ts +++ b/jsapp/js/bemComponents.ts @@ -37,17 +37,6 @@ bem.EmptyContent__title = makeBem(bem.EmptyContent, 'title', 'h1'); bem.EmptyContent__message = makeBem(bem.EmptyContent, 'message', 'p'); bem.EmptyContent__button = makeBem(bem.EmptyContent, 'button', 'button'); -bem.AssetRow = makeBem(null, 'asset-row', 'li'); -bem.AssetRow__cell = makeBem(bem.AssetRow, 'cell'); -bem.AssetRow__cellmeta = makeBem(bem.AssetRow, 'cellmeta'); -bem.AssetRow__description = makeBem(bem.AssetRow, 'description', 'span'); -bem.AssetRow__tags = makeBem(bem.AssetRow, 'tags'); -bem.AssetRow__tags__tag = makeBem(bem.AssetRow, 'tags__tag', 'span'); -bem.AssetRow__tags__notags = makeBem(bem.AssetRow, 'tags__notags', 'span'); -bem.AssetRow__actionIcon = makeBem(bem.AssetRow, 'action-icon', 'a'); -bem.AssetRow__buttons = makeBem(bem.AssetRow, 'buttons'); -bem.AssetRow__typeIcon = makeBem(bem.AssetRow, 'type-icon', 'span'); - bem.ServiceRow = makeBem(null, 'service-row'); bem.ServiceRow__column = makeBem(bem.ServiceRow, 'column'); bem.ServiceRow__actionButton = makeBem(bem.ServiceRow, 'action-button', 'button'); @@ -94,7 +83,6 @@ bem.SearchInput = makeBem(null, 'search-input', 'input'); bem.Search = makeBem(null, 'search'); bem.Search__icon = makeBem(bem.Search, 'icon', 'i'); bem.Search__cancel = makeBem(bem.Search, 'cancel', 'i'); -bem.Search__summary = makeBem(bem.Search, 'summary'); bem.LibNav = makeBem(null, 'lib-nav'); bem.LibNav__content = makeBem(bem.LibNav, 'content'); @@ -136,16 +124,6 @@ bem.CollectionNav__link = makeBem(bem.CollectionNav, 'link', 'a'); bem.CollectionNav__searchcancel = makeBem(bem.CollectionNav, 'searchcancel', 'i'); bem.CollectionNav__searchicon = makeBem(bem.CollectionNav, 'searchicon', 'i'); -bem.List = makeBem(null, 'list'); -bem.List__heading = makeBem(bem.List, 'heading'); -bem.List__subheading = makeBem(bem.List, 'subheading'); - -bem.AssetList = makeBem(null, 'asset-list'); -bem.AssetItems = makeBem(null, 'asset-items', 'ul'); - -bem.AssetListSorts = makeBem(null, 'asset-list-sorts', 'div'); -bem.AssetListSorts__item = makeBem(bem.AssetListSorts, 'item'); - bem.FormView = makeBem(null, 'form-view'); // used in header.es6 bem.FormView__title = makeBem(bem.FormView, 'title'); @@ -153,7 +131,6 @@ bem.FormView__name = makeBem(bem.FormView, 'name'); bem.FormView__description = makeBem(bem.FormView, 'description'); bem.FormView__subs = makeBem(bem.FormView, 'subs'); // end used in header.es6 -bem.FormView__toptabs = makeBem(bem.FormView, 'toptabs'); bem.FormView__sidetabs = makeBem(bem.FormView, 'sidetabs'); bem.FormView__label = makeBem(bem.FormView, 'label'); @@ -177,12 +154,6 @@ bem.FormView__map = makeBem(bem.FormView, 'map'); bem.FormView__mapButton = makeBem(bem.FormView, 'map-button'); bem.FormView__mapList = makeBem(bem.FormView, 'map-list'); -bem.MainHeader = makeBem(null, 'main-header', 'header'); -bem.MainHeader__icon = makeBem(bem.MainHeader, 'icon', 'i'); -bem.MainHeader__title = makeBem(bem.MainHeader, 'title'); -bem.MainHeader__counter = makeBem(bem.MainHeader, 'counter'); - - bem.ReportView = makeBem(null, 'report-view'); bem.ReportView__wrap = makeBem(bem.ReportView, 'wrap'); bem.ReportView__item = makeBem(bem.ReportView, 'item'); diff --git a/jsapp/js/components/assetrow.es6 b/jsapp/js/components/assetrow.es6 deleted file mode 100644 index 625e88da97..0000000000 --- a/jsapp/js/components/assetrow.es6 +++ /dev/null @@ -1,454 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import reactMixin from 'react-mixin'; -import autoBind from 'react-autobind'; -import { Link } from 'react-router-dom'; -import bem from 'js/bem'; -import assetUtils from 'js/assetUtils'; -import PopoverMenu from 'js/popoverMenu'; -import {stores} from '../stores'; -import mixins from '../mixins'; -import { - KEY_CODES, - ASSET_TYPES -} from 'js/constants'; -import TagInput from 'js/components/tagInput'; -import AssetName from 'js/components/common/assetName'; -import {formatTime} from 'utils'; -import {userCan} from 'js/components/permissions/utils'; - -class AssetRow extends React.Component { - constructor(props){ - super(props); - this.state = { - isTagsInputVisible: false, - clearPopover: false, - popoverVisible: false - }; - this.escFunction = this.escFunction.bind(this); - autoBind(this); - } - - clickAssetButton (evt) { - var clickedActionIcon = $(evt.target).closest('[data-action]').get(0); - if (clickedActionIcon) { - var action = clickedActionIcon.getAttribute('data-action'); - var name = clickedActionIcon.getAttribute('data-asset-name') || t('untitled'); - stores.selectedAsset.toggleSelect(this.props.uid, true); - this.props.onActionButtonClick(action, this.props.uid, name); - } - } - - clickTagsToggle () { - const isTagsInputVisible = !this.state.isTagsInputVisible; - if (isTagsInputVisible) { - document.addEventListener('keydown', this.escFunction); - } else { - document.removeEventListener('keydown', this.escFunction); - } - this.setState({isTagsInputVisible: isTagsInputVisible}); - } - - escFunction (evt) { - if (evt.keyCode === KEY_CODES.ESC && this.state.isTagsInputVisible) { - this.clickTagsToggle(); - } - } - - moveToCollection (evt) { - assetUtils.moveToCollection(this.props.uid, evt.currentTarget.dataset.collid); - } - - forceClosePopover () { - if (this.state.popoverVisible) { - this.setState({clearPopover: true, popoverVisible: false}); - } - } - - popoverSetVisible () { - this.setState({clearPopover: false, popoverVisible: true}); - } - - render () { - const isSelfOwned = assetUtils.isSelfOwned(this.props); - var _rc = this.props.summary && this.props.summary.row_count || 0; - - var hrefTo = `/forms/${this.props.uid}`, - tags = this.props.tags || [], - ownedCollections = [], - parent = undefined; - - var isDeployable = this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.deployed_version_id === null; - - const userCanEdit = userCan('change_asset', this.props); - - const assetName = this.props.name || this.props.firstQuestionLabel; - - if (this.props.has_deployment && this.props.deployment__submission_count && - userCan('view_submissions', this.props)) { - hrefTo = `/forms/${this.props.uid}/summary`; - } - - if (this.isLibrary()) { - hrefTo = `/library/asset/${this.props.uid}`; - parent = this.state.parent || undefined; - ownedCollections = this.props.ownedCollections.map(function(c){ - var p = false; - if (parent != undefined && parent.indexOf(c.uid) !== -1) { - p = true; - } - return { - value: c.uid, - label: c.name || c.uid, - hasParent: p - }; - }); - } - - return ( - - - - - {/* "title" column */} - - { this.props.asset_type && ( - this.props.asset_type == ASSET_TYPES.template.id || - this.props.asset_type == ASSET_TYPES.block.id || - this.props.asset_type == ASSET_TYPES.question.id - ) && - {_rc} - } - - - - { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.settings.description && - - {this.props.settings.description} - - } - - - {/* "type" column for library types */} - { this.props.asset_type && ( - this.props.asset_type == ASSET_TYPES.template.id || - this.props.asset_type == ASSET_TYPES.block.id || - this.props.asset_type == ASSET_TYPES.question.id - ) && - - {ASSET_TYPES[this.props.asset_type].label} - - } - - {/* "user" column */} - - { this.props.asset_type == ASSET_TYPES.survey.id && - { isSelfOwned ? ' ' : this.props.owner__username } - } - { this.props.asset_type != ASSET_TYPES.survey.id && - {assetUtils.getAssetOwnerDisplayName(this.props.owner__username)} - } - - - {/* "date created" column for surveys */} - { this.props.asset_type == ASSET_TYPES.survey.id && - - {formatTime(this.props.date_created)} - - } - {/* "date modified" column */} - - {formatTime(this.props.date_modified)} - - - {/* "submission count" column for surveys */} - { this.props.asset_type == ASSET_TYPES.survey.id && - - { - this.props.deployment__submission_count ? - this.props.deployment__submission_count : 0 - } - - } - - - { this.state.isTagsInputVisible && - - - - } - - - {userCanEdit && - - - - } - - {userCanEdit && - - - - } - - {userCanEdit && - - - - } - - - - - - { this.props.asset_type && - this.props.asset_type === ASSET_TYPES.template.id && - userCanEdit && - - - - } - - { this.props.asset_type === ASSET_TYPES.collection.id && - [/*'view',*/ 'sharing'].map((actn)=>{ - return ( - - - - ); - }) - } - - - - } - clearPopover={this.state.clearPopover} - popoverSetVisible={this.popoverSetVisible} - > - { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && userCanEdit && isDeployable && - - - {t('Deploy this project')} - - } - { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.has_deployment && !this.props.deployment__active && userCanEdit && - - - {t('Unarchive')} - - } - { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && userCanEdit && - - - {t('Replace form')} - - } - { userCanEdit && - - - {t('Manage translations')} - - } - { /* temporarily disabled - - - {t('Manage Encryption')} - - */ } - {this.props.downloads.map((dl)=>{ - return ( - - - {t('Download')}  - {dl.format.toString().toUpperCase()} - - ); - })} - { this.props.asset_type && this.props.asset_type != ASSET_TYPES.survey.id && ownedCollections.length > 0 && - - {t('Move to')} - - } - { this.props.asset_type && this.props.asset_type != ASSET_TYPES.survey.id && ownedCollections.length > 0 && - - {ownedCollections.map((col)=>{ - return ( - - - {col.label} - - ); - })} - - } - { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && this.props.has_deployment && this.props.deployment__active && userCanEdit && - - - {t('Archive')} - - } - { this.props.asset_type && this.props.asset_type === ASSET_TYPES.survey.id && userCanEdit && - - - {t('Create template')} - - } - {isSelfOwned && - - - {t('Delete')} - - } - {!isSelfOwned && - - - {t('Remove shared form')} - - } - - - - ); - } -}; - -reactMixin(AssetRow.prototype, mixins.droppable); -reactMixin(AssetRow.prototype, mixins.contextRouter); - -AssetRow.contextTypes = { - router: PropTypes.object -}; - -export default AssetRow; diff --git a/jsapp/js/components/assetsTable/assetActionButtons.tsx b/jsapp/js/components/assetsTable/assetActionButtons.tsx index c12a8e994a..f0fd4026dc 100644 --- a/jsapp/js/components/assetsTable/assetActionButtons.tsx +++ b/jsapp/js/components/assetsTable/assetActionButtons.tsx @@ -27,6 +27,19 @@ import type {OwnedCollectionsStoreData} from 'js/components/library/ownedCollect import './assetActionButtons.scss'; import {withRouter} from 'jsapp/js/router/legacy'; import type {WithRouterProps} from 'jsapp/js/router/legacy'; +import { + archiveAsset, + deleteAsset, + unarchiveAsset, + cloneAsset, + cloneAssetAsSurvey, + cloneAssetAsTemplate, + manageAssetSharing, + replaceAssetForm, + modifyAssetTags, + manageAssetLanguages, + manageAssetSettings +} from 'jsapp/js/assetQuickActions'; import {userCan} from 'js/components/permissions/utils'; bem.AssetActionButtons = makeBem(null, 'asset-action-buttons', 'menu'); @@ -37,8 +50,6 @@ bem.AssetActionButtons__iconButton = makeBem( 'a' ); -const assetActions = mixins.clickAssets.click.asset; - interface AssetActionButtonsProps extends WithRouterProps { asset: AssetResponse; has_deployment?: boolean; @@ -125,27 +136,27 @@ class AssetActionButtons extends React.Component< // Methods for managing the asset modifyDetails() { - assetUtils.modifyDetails(this.props.asset); + manageAssetSettings(this.props.asset); } editLanguages() { - assetUtils.editLanguages(this.props.asset); + manageAssetLanguages(this.props.asset.uid); } share() { - assetUtils.share(this.props.asset); + manageAssetSharing(this.props.asset.uid); } showTagsModal() { - assetUtils.editTags(this.props.asset); + modifyAssetTags(this.props.asset); } replace() { - assetUtils.replaceForm(this.props.asset); + replaceAssetForm(this.props.asset); } delete() { - assetActions.delete( + deleteAsset( this.props.asset, assetUtils.getAssetDisplayName(this.props.asset).final, this.onDeleteComplete.bind(this, this.props.asset.uid) @@ -153,7 +164,7 @@ class AssetActionButtons extends React.Component< } /** - * Navigates out of nonexistent paths after asset was successfuly deleted + * Navigates out of nonexistent paths after asset was successfully deleted */ onDeleteComplete(assetUid: string) { if (isAnyLibraryItemRoute() && getRouteAssetUid() === assetUid) { @@ -169,26 +180,26 @@ class AssetActionButtons extends React.Component< } archive() { - assetActions.archive(this.props.asset); + archiveAsset(this.props.asset); } unarchive() { - assetActions.unarchive(this.props.asset); + unarchiveAsset(this.props.asset); } clone() { - assetActions.clone(this.props.asset); + cloneAsset(this.props.asset); } cloneAsSurvey() { - assetActions.cloneAsSurvey( + cloneAssetAsSurvey( this.props.asset.uid, assetUtils.getAssetDisplayName(this.props.asset).final ); } cloneAsTemplate() { - assetActions.cloneAsTemplate( + cloneAssetAsTemplate( this.props.asset.uid, assetUtils.getAssetDisplayName(this.props.asset).final ); diff --git a/jsapp/js/components/assetsTable/assetsTableRow.tsx b/jsapp/js/components/assetsTable/assetsTableRow.tsx index 7319a97420..d7fc92f7aa 100644 --- a/jsapp/js/components/assetsTable/assetsTableRow.tsx +++ b/jsapp/js/components/assetsTable/assetsTableRow.tsx @@ -8,6 +8,7 @@ import {ASSET_TYPES} from 'js/constants'; import assetUtils from 'js/assetUtils'; import type {AssetsTableContextName} from './assetsTableConstants'; import {ASSETS_TABLE_CONTEXTS} from './assetsTableConstants'; +import Icon from 'js/components/common/icon'; interface AssetsTableRowProps { asset: AssetResponse; @@ -16,11 +17,6 @@ interface AssetsTableRowProps { class AssetsTableRow extends React.Component { render() { - let iconClassName = ''; - if (this.props.asset) { - iconClassName = assetUtils.getAssetIcon(this.props.asset); - } - let rowCount = null; if ( this.props.asset.asset_type !== ASSET_TYPES.collection.id && @@ -43,7 +39,7 @@ class AssetsTableRow extends React.Component { - + diff --git a/jsapp/js/components/common/assetStatusBadge.tsx b/jsapp/js/components/common/assetStatusBadge.tsx new file mode 100644 index 0000000000..fad3c84d63 --- /dev/null +++ b/jsapp/js/components/common/assetStatusBadge.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import Badge from 'js/components/common/badge'; +import type {AssetResponse, ProjectViewAsset} from 'js/dataInterface'; + +interface AssetStatusBadgeProps { + asset: AssetResponse | ProjectViewAsset; +} + +/** + * Displays a small colorful badge with an icon. The badge tells whether + * the project is draft, deployed, or archived. + */ +export default function AssetStatusBadge(props: AssetStatusBadgeProps) { + if (props.asset.deployment_status === 'archived') { + return ( + + ); + } else if (props.asset.deployment_status === 'deployed') { + return ( + + ); + } else { + return ( + + ); + } +} diff --git a/jsapp/js/components/common/checkbox.scss b/jsapp/js/components/common/checkbox.scss index 1ca99ae617..cb5086388d 100644 --- a/jsapp/js/components/common/checkbox.scss +++ b/jsapp/js/components/common/checkbox.scss @@ -87,7 +87,7 @@ top: calc(50% - sizes.$x5); left: calc(50% - sizes.$x6); transform: rotate(-45deg); - border: sizes.$x3 solid currentColor; + border: sizes.$x3 solid currentcolor; border-top: none; border-right: none; width: sizes.$x10; @@ -103,5 +103,11 @@ &::after {opacity: 1;} } + + // Keyboard focus styles + &:focus-visible { + outline: 3px solid colors.$kobo-alt-blue; + outline-offset: sizes.$x1; + } } } diff --git a/jsapp/js/components/common/koboDropdown.stories.tsx b/jsapp/js/components/common/koboDropdown.stories.tsx index ceb3d25421..bbc720e4d3 100644 --- a/jsapp/js/components/common/koboDropdown.stories.tsx +++ b/jsapp/js/components/common/koboDropdown.stories.tsx @@ -1,15 +1,13 @@ import React from 'react'; -import {ComponentStory, ComponentMeta} from '@storybook/react'; -import KoboDropdown, { - KoboDropdownPlacements, -} from 'js/components/common/koboDropdown'; +import type {ComponentStory, ComponentMeta} from '@storybook/react'; +import KoboDropdown from 'js/components/common/koboDropdown'; export default { title: 'common/KoboDropdown', component: KoboDropdown, argTypes: { placement: { - options: KoboDropdownPlacements, + options: ['down-center', 'down-left', 'down-right', 'up-center', 'up-left', 'up-right'], control: {type: 'select'}, }, isDisabled: { @@ -25,7 +23,7 @@ const Template: ComponentStory = (args) => ( export const Primary = Template.bind({}); Primary.args = { name: 'kobo-dropdown-demo', - placement: KoboDropdownPlacements['down-center'], + placement: 'down-center', triggerContent: 'click me', menuContent: (
    diff --git a/jsapp/js/components/common/koboDropdown.tsx b/jsapp/js/components/common/koboDropdown.tsx index 683ee7e1f8..e51934cfba 100644 --- a/jsapp/js/components/common/koboDropdown.tsx +++ b/jsapp/js/components/common/koboDropdown.tsx @@ -7,18 +7,13 @@ import { import koboDropdownActions from './koboDropdownActions'; import './koboDropdown.scss'; -export enum KoboDropdownPlacements { - 'up-left' = 'up-left', - 'up-center' = 'up-center', - 'up-right' = 'up-right', - 'down-left' = 'down-left', - 'down-center' = 'down-center', - 'down-right' = 'down-right', -} +export type KoboDropdownPlacement = 'down-center' | 'down-left' | 'down-right' | 'up-center' | 'up-left' | 'up-right'; + +const DEFAULT_PLACEMENT: KoboDropdownPlacement = 'down-center'; interface KoboDropdownProps { - placement: KoboDropdownPlacements; - /** Disables the dropdowns trigger, thus disallowing opening dropdow. */ + placement: KoboDropdownPlacement; + /** Disables the dropdowns trigger, thus disallowing opening dropdown. */ isDisabled?: boolean; /** Hides menu whenever user clicks inside it, useful for simple menu with a list of actions. */ hideOnMenuClick: boolean; @@ -188,12 +183,11 @@ export default class KoboDropdown extends React.Component< const wrapperMods = []; if ( - this.props.placement && - typeof KoboDropdownPlacements[this.props.placement] !== 'undefined' + this.props.placement ) { wrapperMods.push(this.props.placement); } else { - wrapperMods.push(KoboDropdownPlacements['down-center']); + wrapperMods.push(DEFAULT_PLACEMENT); } // These modifiers are for styling purposes only, i.e. they don't have diff --git a/jsapp/js/components/common/koboSelect.tsx b/jsapp/js/components/common/koboSelect.tsx index c302e0f612..45a66d7e68 100644 --- a/jsapp/js/components/common/koboSelect.tsx +++ b/jsapp/js/components/common/koboSelect.tsx @@ -8,7 +8,7 @@ import type {IconSize} from 'js/components/common/icon'; import Icon from 'js/components/common/icon'; import type {ButtonSize} from 'js/components/common/button'; import {ButtonToIconMap} from 'js/components/common/button'; -import KoboDropdown, {KoboDropdownPlacements} from 'js/components/common/koboDropdown'; +import KoboDropdown from 'js/components/common/koboDropdown'; import koboDropdownActions from 'js/components/common/koboDropdownActions'; import './koboSelect.scss'; @@ -328,7 +328,7 @@ class KoboSelect extends React.Component { import("js/account/accountSidebar")); +const AccountSidebar = lazy(() => import('js/account/accountSidebar')); const INITIAL_STATE = { headerFilters: 'forms', @@ -31,57 +29,60 @@ const INITIAL_STATE = { assetType: COMMON_QUERIES.s, }, filterTags: COMMON_QUERIES.s, - }) + }), }; -const FormSidebar = observer(class FormSidebar extends Reflux.Component { - constructor(props){ - super(props); - this.state = assign({ - currentAssetId: false, - files: [] - }, stores.pageState.state); - this.state = assign(INITIAL_STATE, this.state); - - this.stores = [ - stores.pageState - ]; - this.unlisteners = []; - autoBind(this); - } - componentDidMount() { - router.subscribe(this.onRouteChange.bind(this)); - } - componentWillUnmount() { - this.unlisteners.forEach((clb) => {clb();}); - } - newFormModal (evt) { - evt.preventDefault(); - stores.pageState.showModal({ - type: MODAL_TYPES.NEW_FORM - }); - } - render() { - return ( - - - {t('new')} - - - - ); - } - onRouteChange() { - this.setState(INITIAL_STATE); +const FormSidebar = observer( + class FormSidebar extends Reflux.Component { + constructor(props) { + super(props); + this.state = assign( + { + currentAssetId: false, + files: [], + }, + stores.pageState.state + ); + this.state = assign(INITIAL_STATE, this.state); + + this.stores = [stores.pageState]; + autoBind(this); + } + componentDidMount() { + // NOTE: this causes multiple callbacks being fired when using hot reload + // in dev environment. Unfortunately `router.subscribe` doesn't return + // a cancel function, so we can't make it stop. + // TODO: when refactoring this file, make sure not to use the legacy code. + router.subscribe(this.onRouteChange.bind(this)); + } + newFormModal(evt) { + evt.preventDefault(); + stores.pageState.showModal({ + type: MODAL_TYPES.NEW_FORM, + }); + } + render() { + return ( + + + {t('new')} + + + + ); + } + onRouteChange() { + this.setState(INITIAL_STATE); + } } -}); +); FormSidebar.contextTypes = { - router: PropTypes.object + router: PropTypes.object, }; reactMixin(FormSidebar.prototype, searches.common); @@ -92,7 +93,7 @@ class DrawerLink extends React.Component { super(props); autoBind(this); } - onClick (evt) { + onClick(evt) { if (!this.props.href) { evt.preventDefault(); } @@ -100,26 +101,30 @@ class DrawerLink extends React.Component { this.props.onClick(evt); } } - render () { - var icon = (); - var classNames = [this.props.class, 'k-drawer__link']; + render() { + const icon = ; + const classNames = [this.props.class, 'k-drawer__link']; - var link; + let link; if (this.props.linkto) { link = ( - + {icon} ); } else { link = ( - - {icon} + + {icon} ); } @@ -127,75 +132,84 @@ class DrawerLink extends React.Component { } } -const Drawer = observer(class Drawer extends Reflux.Component { - constructor(props){ - super(props); - autoBind(this); - this.stores = [ - stores.pageState, - ]; - } - - isAccount() { - return routerIsActive(ROUTES.ACCOUNT_ROOT); - } +const Drawer = observer( + class Drawer extends Reflux.Component { + constructor(props) { + super(props); + autoBind(this); + this.stores = [stores.pageState]; + } - render() { - // no sidebar for not logged in users - if (!sessionStore.isLoggedIn) { - return null; + isAccount() { + return routerIsActive(ROUTES.ACCOUNT_ROOT); } - return ( - - - - - - - - { this.isLibrary() && - - - - } - - { this.isAccount() && - - - - } - - { !this.isLibrary() && !this.isAccount() && - - - - } - - - - { sessionStore.isLoggedIn && - - } - { envStore.isReady && - envStore.data.source_code_url && - - - - } - - + render() { + // no sidebar for not logged in users + if (!sessionStore.isLoggedIn) { + return null; + } + + return ( + + + + + + + + {this.isLibrary() && ( + + + + )} + + {this.isAccount() && ( + + + + )} + + {!this.isLibrary() && !this.isAccount() && ( + + + + )} + + + + {sessionStore.isLoggedIn && } + {envStore.isReady && envStore.data.source_code_url && ( + + + + )} + + ); + } } -}); +); reactMixin(Drawer.prototype, searches.common); reactMixin(Drawer.prototype, mixins.droppable); reactMixin(Drawer.prototype, mixins.contextRouter); Drawer.contextTypes = { - router: PropTypes.object + router: PropTypes.object, }; export default withRouter(Drawer); diff --git a/jsapp/js/components/formLanding.js b/jsapp/js/components/formLanding.js index 2aaf4e1d81..2c557d44d5 100644 --- a/jsapp/js/components/formLanding.js +++ b/jsapp/js/components/formLanding.js @@ -91,21 +91,21 @@ class FormLanding extends React.Component { - {userCanEdit && this.state.has_deployment && this.state.deployment__active && + {userCanEdit && this.state.deployment_status === 'deployed' && {t('redeploy')} } - {userCanEdit && !this.state.has_deployment && !this.state.deployment__active && + {userCanEdit && this.state.deployment_status === 'draft' && {t('deploy')} } - {userCanEdit && this.state.has_deployment && !this.state.deployment__active && + {userCanEdit && this.state.deployment_status === 'archived' && diff --git a/jsapp/js/components/formSummary.js b/jsapp/js/components/formSummary.js index c1c829cf81..b333caf3b8 100644 --- a/jsapp/js/components/formSummary.js +++ b/jsapp/js/components/formSummary.js @@ -12,10 +12,8 @@ import DocumentTitle from 'react-document-title'; import Icon from 'js/components/common/icon'; import moment from 'moment'; import Chart from 'chart.js'; -import {getFormDataTabs} from './formViewTabs'; -import assetUtils from 'js/assetUtils'; +import {getFormDataTabs} from './formViewSideTabs'; import { - formatTime, formatDate, stringToColor, getUsernameFromUrl, @@ -26,6 +24,7 @@ import { } from 'js/constants'; import './formSummary.scss'; import {userCan} from 'js/components/permissions/utils'; +import FormSummaryProjectInfo from './formSummaryProjectInfo'; class FormSummary extends React.Component { constructor(props) { @@ -33,7 +32,6 @@ class FormSummary extends React.Component { this.state = { subsCurrentPeriod: '', subsPreviousPeriod: '', - lastSubmission: false, chartVisible: false, chart: {}, chartPeriod: 'week', @@ -52,7 +50,6 @@ class FormSummary extends React.Component { prep() { if (this.state.permissions && userCan('view_submissions', this.state)) { const uid = this._getAssetUid(); - this.getLatestSubmissionTime(uid); this.prepSubmissions(uid); } } @@ -139,14 +136,7 @@ class FormSummary extends React.Component { }); } - getLatestSubmissionTime(assetid) { - const fq = ['_id', 'end']; - const sort = [{id: '_id', desc: true}]; - dataInterface.getSubmissions(assetid, 1, 0, sort, fq).done((data) => { - const results = data.results; - if (data.count) {this.setState({lastSubmission: results[0]['end']});} else {this.setState({lastSubmission: false});} - }); - } + renderSubmissionsGraph() { if (!this.state.permissions || !userCan('view_submissions', this.state)) { return null; @@ -356,124 +346,19 @@ class FormSummary extends React.Component { } render () { const docTitle = this.state.name || t('Untitled'); - const hasCountry = ( - this.state.settings?.country && - ( - !Array.isArray(this.state.settings?.country) || - !!this.state.settings?.country.length - ) - ); - const hasSector = Boolean(this.state.settings?.sector?.value); - const hasProjectInfo = ( - this.state.settings && - ( - this.state.settings.description || - hasCountry || - hasSector || - this.state.settings.operational_purpose || - this.state.settings.collects_pii - ) - ); - - // if (!this.state.permissions) { - // return (); - // } return ( - {/* Project information */} - {hasProjectInfo && - - - {t('Project information')} - - - {(hasCountry || hasSector) && - - {hasCountry && - - {t('Country')} - {assetUtils.getCountryDisplayString(this.state)} - - } - {hasSector && - - {t('Sector')} - {assetUtils.getSectorDisplayString(this.state)} - - } - - } - {(this.state.settings.operational_purpose || this.state.settings.collects_pii) && - - {this.state.settings.operational_purpose && - - {t('Operational purpose of data')} - {this.state.settings.operational_purpose.label} - - } - {this.state.settings.collects_pii && - - {t('Collects personally identifiable information')} - {this.state.settings.collects_pii.label} - - } - - } - {this.state.settings.description && - - - {t('Description')} -

    {this.state.settings.description}

    -
    -
    - } -
    -
    + {/* We only want to pass an actual asset object, but because this + component uses `mixins.dmix`, we have to add this little check. */} + {this.state.uid && + } {/* Submissions graph */} {this.renderSubmissionsGraph()} - - {/* Form details */} - - - {t('Form details')} - - - - - {t('Last modified')} - {formatTime(this.state.date_modified)} - - {this.state.lastSubmission && - - {t('Latest submission')} - {formatTime(this.state.lastSubmission)} - - } - {this.state.summary && - - {t('Questions')} - {this.state.summary.row_count} - - } - - {this.state.summary && this.state.summary.languages && this.state.summary.languages.length > 1 && - - {t('Languages')} - {this.state.summary.languages.map((l, i) => ( - - {l} - - ))} - - } - - -
    diff --git a/jsapp/js/components/formSummary.scss b/jsapp/js/components/formSummary.scss index edbc109381..8c673c1352 100644 --- a/jsapp/js/components/formSummary.scss +++ b/jsapp/js/components/formSummary.scss @@ -1,14 +1,5 @@ @use '~kobo-common/src/styles/colors'; -.form-view__group.form-view__group--description-cols { - padding: 20px; - display: flex; - - > .form-view__cell { - width: 50%; - } -} - .form-view__group.form-view__group--items { > .form-view__cell { .form-view__label { @@ -18,36 +9,6 @@ } } -.form-view__row--summary-description { - .form-view__cell--description:not(:first-child) { - border-top: 1px solid colors.$kobo-gray-92; - } - - .form-view__cell--description p { - white-space: pre-wrap; - margin: 0; - } -} - -.form-view__group.form-view__group--summary-details-cols { - display: flex; - align-items: flex-start; - - > .form-view__cell { - padding: 15px 20px; - flex-grow: 1; - - &:first-child { - flex-grow: 2; - } - - .form-view__label { - font-size: 12px; - opacity: 0.6; - } - } -} - .form-view__cell.form-view__cell--subs-graph { padding: 30px; padding-bottom: 0px; diff --git a/jsapp/js/components/formSummaryProjectInfo.tsx b/jsapp/js/components/formSummaryProjectInfo.tsx new file mode 100644 index 0000000000..9fc2a65371 --- /dev/null +++ b/jsapp/js/components/formSummaryProjectInfo.tsx @@ -0,0 +1,167 @@ +import React, {useState, useEffect} from 'react'; +import bem from 'js/bem'; +import { + getCountryDisplayString, + getSectorDisplayString, + isSelfOwned, +} from 'js/assetUtils'; +import type { + AssetResponse, + PaginatedResponse, + SubmissionResponse, +} from 'js/dataInterface'; +import {dataInterface} from 'js/dataInterface'; +import {formatTime} from 'js/utils'; +import AssetStatusBadge from './common/assetStatusBadge'; +import Avatar from './common/avatar'; + +interface FormSummaryProjectInfoProps { + asset: AssetResponse; +} + +export default function FormSummaryProjectInfo( + props: FormSummaryProjectInfoProps +) { + // NOTE: this will only work with forms that have `end` meta question enabled + const [latestSubmissionDate, setLatestSubmissionDate] = useState< + string | undefined + >(); + + useEffect(() => { + // Fetches one last submission, and only two fields for it. + dataInterface + .getSubmissions( + props.asset?.uid, + 1, + 0, + [{id: '_id', desc: true}], + ['_id', 'end'] + ) + .done((response: PaginatedResponse) => { + if (response.count) { + setLatestSubmissionDate(response.results[0]['end']); + } + }); + }, []); + + const lastDeployedDate = + props.asset.deployed_versions?.results?.[0]?.date_modified; + + return ( + + + {t('Project information')} + + + + + {/* description - takes whole row */} + + {t('Description')} + {props.asset.settings.description || '-'} + + + + + {/* status */} + + {t('Status')} + + + + {/* questions count */} + + {t('Questions')} + {props.asset.summary.row_count || '-'} + + + {/* owner */} + + {t('Owner')} + {isSelfOwned(props.asset) && t('me')} + {!isSelfOwned(props.asset) && ( + + )} + + + + + {/* date modified */} + + {t('Last modified')} + {formatTime(props.asset.date_modified)} + + + {/* date deployed */} + + {t('Last deployed')} + {lastDeployedDate && formatTime(lastDeployedDate)} + {!lastDeployedDate && '-'} + + + {/* date of last submission */} + {latestSubmissionDate && ( + + + {t('Latest submission')} + + {formatTime(latestSubmissionDate)} + + )} + + + + {/* sector */} + + {t('Sector')} + {getSectorDisplayString(props.asset)} + + + {/* countries */} + + {t('Countries')} + {getCountryDisplayString(props.asset)} + + + + {/* languages */} + {props.asset.summary?.languages && + props.asset.summary.languages.length > 1 && ( + + + {t('Languages')} + {props.asset.summary.languages.map((language, index) => ( + + {language} + + ))} + + + )} + + {/* operational purpose and PII */} + {(props.asset.settings.operational_purpose || + props.asset.settings.collects_pii) && ( + + {props.asset.settings.operational_purpose && ( + + + {t('Operational purpose of data')} + + {props.asset.settings.operational_purpose.label} + + )} + {props.asset.settings.collects_pii && ( + + + {t('Collects personally identifiable information')} + + {props.asset.settings.collects_pii.label} + + )} + + )} + + + ); +} diff --git a/jsapp/js/components/formViewTabs.es6 b/jsapp/js/components/formViewSideTabs.es6 similarity index 63% rename from jsapp/js/components/formViewTabs.es6 rename to jsapp/js/components/formViewSideTabs.es6 index 2be90fbe66..a82690efbc 100644 --- a/jsapp/js/components/formViewTabs.es6 +++ b/jsapp/js/components/formViewSideTabs.es6 @@ -4,15 +4,14 @@ import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; import Reflux from 'reflux'; import bem from 'js/bem'; -import sessionStore from 'js/stores/session'; import assetStore from 'js/assetStore'; -import {Link, NavLink} from 'react-router-dom'; +import {NavLink} from 'react-router-dom'; import mixins from '../mixins'; import {PERMISSIONS_CODENAMES} from 'js/constants'; import {ROUTES} from 'js/router/routerConstants'; import {withRouter} from 'js/router/legacy'; import {assign} from 'utils'; -import {userCan, userCanPartially} from 'js/components/permissions/utils'; +import {userCan} from 'js/components/permissions/utils'; export function getFormDataTabs(assetUid) { return [ @@ -44,7 +43,7 @@ export function getFormDataTabs(assetUid) { ]; } -class FormViewTabs extends Reflux.Component { +class FormViewSideTabs extends Reflux.Component { constructor(props) { super(props); this.state = {}; @@ -64,8 +63,9 @@ class FormViewTabs extends Reflux.Component { triggerRefresh(evt) { if ($(evt.target).hasClass('active')) { - // ROUTES.FORM_RESET - this.props.router.navigate(`/forms/${this.state.asset.uid}/reset`); + this.props.router.navigate( + ROUTES.FORM_RESET.replace(':uid', this.state.asset.uid) + ); var path = evt.target.getAttribute('data-path'); window.setTimeout(() => { @@ -76,87 +76,6 @@ class FormViewTabs extends Reflux.Component { } } - isDataTabEnabled() { - return ( - this.state.asset.deployment__identifier != undefined && - this.state.asset.has_deployment && - this.state.asset.deployment__submission_count > 0 && - ( - userCan('view_submissions', this.state.asset) || - userCanPartially('view_submissions', this.state.asset) - ) - ); - } - - renderTopTabs() { - if (this.state.asset === undefined) { - return false; - } - - let dataTabClassNames = 'form-view__tab'; - if (!this.isDataTabEnabled()) { - dataTabClassNames += ' form-view__tab--disabled'; - } - - let summaryTabClassNames = 'form-view__tab'; - if (!sessionStore.isLoggedIn) { - summaryTabClassNames += ' form-view__tab--disabled'; - } - - let settingsTabClassNames = 'form-view__tab'; - if ( - !( - sessionStore.isLoggedIn && ( - userCan('change_asset', this.state.asset) || - userCan('change_metadata_asset', this.state.asset) - ) - ) - ) { - settingsTabClassNames += ' form-view__tab--disabled'; - } - - return ( - - - {t('Summary')} - - - - {t('Form')} - - - - {t('Data')} - - - - {t('Settings')} - - - {sessionStore.isLoggedIn && ( - - - - )} - - ); - } - renderFormSideTabs() { var sideTabs = []; @@ -277,24 +196,15 @@ class FormViewTabs extends Reflux.Component { if (!this.props.show) { return false; } - if (this.props.type === 'top') { - return ( - this.renderTopTabs() - ); - } - if (this.props.type === 'side') { - return ( - this.renderFormSideTabs() - ); - } + return this.renderFormSideTabs(); } } -reactMixin(FormViewTabs.prototype, Reflux.ListenerMixin); -reactMixin(FormViewTabs.prototype, mixins.contextRouter); +reactMixin(FormViewSideTabs.prototype, Reflux.ListenerMixin); +reactMixin(FormViewSideTabs.prototype, mixins.contextRouter); -FormViewTabs.contextTypes = { +FormViewSideTabs.contextTypes = { router: PropTypes.object, }; -export default withRouter(FormViewTabs); +export default withRouter(FormViewSideTabs); diff --git a/jsapp/js/components/header.es6 b/jsapp/js/components/header.es6 deleted file mode 100644 index cb3fa5d335..0000000000 --- a/jsapp/js/components/header.es6 +++ /dev/null @@ -1,349 +0,0 @@ -;import React from 'react'; -import PropTypes from 'prop-types'; -import reactMixin from 'react-mixin'; -import { observer } from 'mobx-react'; -import autoBind from 'react-autobind'; -import PopoverMenu from 'js/popoverMenu'; -import {stores} from '../stores'; -import sessionStore from 'js/stores/session'; -import assetStore from 'js/assetStore'; -import {withRouter} from 'js/router/legacy'; -import Reflux from 'reflux'; -import bem from 'js/bem'; -import {actions} from '../actions'; -import mixins from '../mixins'; -import {dataInterface} from '../dataInterface'; -import { - assign, - currentLang, - stringToColor, -} from 'utils'; -import {getLoginUrl} from 'js/router/routerUtils'; -import {getAssetIcon} from 'js/assetUtils'; -import {COMMON_QUERIES} from 'js/constants'; -import {ACCOUNT_ROUTES} from 'js/account/routes'; -import {searches} from '../searches'; -import {ListSearch} from '../components/list'; -import HeaderTitleEditor from 'js/components/header/headerTitleEditor'; -import SearchBox from 'js/components/header/searchBox'; -import myLibraryStore from 'js/components/library/myLibraryStore'; -import envStore from 'js/envStore'; -import {userCan} from 'js/components/permissions/utils'; - -const MainHeader = class MainHeader extends Reflux.Component { - constructor(props){ - super(props); - this.state = assign({ - asset: false, - isLanguageSelectorVisible: false, - formFiltersContext: searches.getSearchContext('forms', { - filterParams: { - assetType: COMMON_QUERIES.s, - }, - filterTags: COMMON_QUERIES.s, - }), - }, stores.pageState.state); - this.stores = [ - stores.pageState, - ]; - this.unlisteners = []; - autoBind(this); - } - - componentDidMount() { - // On initial load use the possibly stored asset. - this.setState({asset: assetStore.getAsset(this.currentAssetID())}) - - this.unlisteners.push( - assetStore.listen(this.onAssetLoad), - myLibraryStore.listen(this.forceRender) - ); - } - - componentWillUnmount() { - this.unlisteners.forEach((clb) => {clb();}); - } - - /* - * NOTE: this should be updated to `getDerivedStateFromProps` but causes Error: - * Warning: Unsafe legacy lifecycles will not be called for components using new component APIs. - * MainHeader uses getDerivedStateFromProps() but also contains the following legacy lifecycles: - * componentWillMount - */ - componentWillUpdate(newProps) { - if (this.props.assetid !== newProps.assetid) { - this.setState({asset: false}); - // we need new asset here, but instead of duplicating a call, we wait for - // action triggered by other component (route component) - } - } - - componentDidUpdate(prevProps) { - if (prevProps.assetid !== this.props.assetid && this.props && this.props.assetid) { - actions.resources.loadAsset({id: this.props.assetid}); - } - } - - forceRender() { - this.setState(this.state); - } - - isSearchBoxDisabled() { - if (this.isMyLibrary()) { - // disable search when user has zero assets - return myLibraryStore.getCurrentUserTotalAssets() === null; - } else { - return false; - } - } - - onAssetLoad(data) { - const asset = data[this.props.assetid]; - this.setState(assign({asset: asset})); - } - - logout() { - actions.auth.logout(); - } - - toggleLanguageSelector() { - this.setState({isLanguageSelectorVisible: !this.state.isLanguageSelectorVisible}); - } - - accountSettings() { - // verifyLogin also refreshes stored profile data - actions.auth.verifyLogin.triggerAsync().then(() => { - this.props.router.navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS); - }); - } - - languageChange(evt) { - evt.preventDefault(); - let langCode = $(evt.target).data('key'); - if (langCode) { - // use .always (instead of .done) here since Django 1.8 redirects the request - dataInterface.setLanguage({language: langCode}).always(() => { - if ('reload' in window.location) { - window.location.reload(); - } else { - window.alert(t('Please refresh the page')); - } - }); - } - } - - renderLangItem(lang) { - const currentLanguage = currentLang(); - return ( - - - {lang.value === currentLanguage && - {lang.label} - } - {lang.value !== currentLanguage && - lang.label - } - - - ); - } - - renderLoginButton() { - return ( - -
    - {t('Log In')} - - - ); - } - - renderAccountNavMenu() { - let shouldDisplayUrls = false; - if ( - envStore.isReady && - typeof envStore.data.terms_of_service_url === 'string' && - typeof envStore.data.terms_of_service_url.length >= 1 - ) { - shouldDisplayUrls = true; - } - if ( - envStore.isReady && - typeof envStore.data.privacy_policy_url === 'string' && - typeof envStore.data.privacy_policy_url.length >= 1 - ) { - shouldDisplayUrls = true; - } - - let langs = []; - if (envStore.isReady && envStore.data.interface_languages) { - langs = envStore.data.interface_languages; - } - if (sessionStore.isLoggedIn) { - var accountName = sessionStore.currentAccount.username; - var accountEmail = sessionStore.currentAccount.email; - - var initialsStyle = {background: `#${stringToColor(accountName)}`}; - var accountMenuLabel = {accountName.charAt(0)}; - - return ( - - - - - - {accountMenuLabel} - - - {accountName} - {accountEmail} - - - - {t('Account Settings')} - - - - {shouldDisplayUrls && - - {envStore.data.terms_of_service_url && - - {t('Terms of Service')} - - } - {envStore.data.privacy_policy_url && - - {t('Privacy Policy')} - - } - - } - - - - {t('Language')} - - - {this.state.isLanguageSelectorVisible && -
      - {langs.map(this.renderLangItem)} -
    - } -
    - - - - {t('Logout')} - - -
    -
    -
    - ); - } - - return null; - } - - renderGitRevInfo() { - if (sessionStore.currentAccount && sessionStore.currentAccount.git_rev) { - var gitRev = sessionStore.currentAccount.git_rev; - return ( - - - branch: {gitRev.branch} - - - commit: {gitRev.short} - - - ); - } - - return false; - } - - toggleFixedDrawer() { - stores.pageState.toggleFixedDrawer(); - } - - render() { - const isLoggedIn = sessionStore.isLoggedIn; - - let userCanEditAsset = false; - if (this.state.asset) { - userCanEditAsset = userCan('change_asset', this.state.asset); - } - - let iconClassName = ''; - if (this.state.asset) { - iconClassName = getAssetIcon(this.state.asset); - } - - let librarySearchBoxPlaceholder = t('Search My Library'); - if (this.isPublicCollections()) { - librarySearchBoxPlaceholder = t('Search Public Collections'); - } - - return ( - -
    - {sessionStore.isLoggedIn && - - - - } - - - - - - { isLoggedIn && this.isFormList() && -
    - -
    - } - { isLoggedIn && (this.isMyLibrary() || this.isPublicCollections()) && -
    - -
    - } - { !this.isLibrary() && this.state.asset && this.isFormSingle() && - - - - - - { this.isFormSingle() && this.state.asset.has_deployment && - - {this.state.asset.deployment__submission_count} {t('submissions')} - - } - - } - {this.renderAccountNavMenu()} - { !isLoggedIn && this.renderLoginButton()} -
    - {this.renderGitRevInfo()} -
    - ); - } -} - -reactMixin(MainHeader.prototype, Reflux.ListenerMixin); -reactMixin(MainHeader.prototype, mixins.contextRouter); - -MainHeader.contextTypes = {router: PropTypes.object}; - -export default observer(withRouter(MainHeader)); diff --git a/jsapp/js/components/header/accountMenu.tsx b/jsapp/js/components/header/accountMenu.tsx new file mode 100644 index 0000000000..4fc472cfe9 --- /dev/null +++ b/jsapp/js/components/header/accountMenu.tsx @@ -0,0 +1,141 @@ +import React, {useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import PopoverMenu from 'js/popoverMenu'; +import sessionStore from 'js/stores/session'; +import bem from 'js/bem'; +import {currentLang, stringToColor} from 'js/utils'; +import envStore from 'js/envStore'; +import type {LabelValuePair} from 'js/dataInterface'; +import {dataInterface} from 'js/dataInterface'; +import {actions} from 'js/actions'; +import {ACCOUNT_ROUTES} from 'jsapp/js/account/routes'; + +export default function AccountMenu() { + const navigate = useNavigate(); + + const [isLanguageSelectorVisible, setIsLanguageSelectorVisible] = + useState(false); + const toggleLanguageSelector = () => { + setIsLanguageSelectorVisible(!isLanguageSelectorVisible); + }; + + const shouldDisplayUrls = + (typeof envStore.data.terms_of_service_url === 'string' && + envStore.data.terms_of_service_url !== '') || + (typeof envStore.data.privacy_policy_url === 'string' && + envStore.data.privacy_policy_url !== ''); + + let langs: LabelValuePair[] = []; + if (envStore.isReady && envStore.data.interface_languages) { + langs = envStore.data.interface_languages; + } + + const onLanguageChange = (langCode: string) => { + if (langCode) { + // use .always (instead of .done) here since Django 1.8 redirects the request + dataInterface.setLanguage({language: langCode}).always(() => { + if ('reload' in window.location) { + window.location.reload(); + } else { + window.alert(t('Please refresh the page')); + } + }); + } + }; + + const renderLangItem = (lang: LabelValuePair) => { + const currentLanguage = currentLang(); + return ( + + onLanguageChange(lang.value)}> + {lang.value === currentLanguage && {lang.label}} + {lang.value !== currentLanguage && lang.label} + + + ); + }; + + const openAccountSettings = () => { + navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS); + }; + + if (!sessionStore.isLoggedIn) { + return null; + } + + const accountName = sessionStore.currentAccount.username; + const accountEmail = + 'email' in sessionStore.currentAccount + ? sessionStore.currentAccount.email + : ''; + + const initialsStyle = {background: `#${stringToColor(accountName)}`}; + const accountMenuLabel = ( + + {accountName.charAt(0)} + + ); + + return ( + + + + + + {accountMenuLabel} + + + + {accountName} + {accountEmail} + + + + + {t('Account Settings')} + + + + + {shouldDisplayUrls && ( + + {envStore.data.terms_of_service_url && ( + + {t('Terms of Service')} + + )} + {envStore.data.privacy_policy_url && ( + + {t('Privacy Policy')} + + )} + + )} + + + + + {t('Language')} + + + {isLanguageSelectorVisible &&
      {langs.map(renderLangItem)}
    } +
    + + + + + {t('Logout')} + + +
    +
    +
    + ); +} diff --git a/jsapp/js/components/header/headerTitleEditor.es6 b/jsapp/js/components/header/headerTitleEditor.tsx similarity index 80% rename from jsapp/js/components/header/headerTitleEditor.es6 rename to jsapp/js/components/header/headerTitleEditor.tsx index d097e6a58c..0c2c166472 100644 --- a/jsapp/js/components/header/headerTitleEditor.es6 +++ b/jsapp/js/components/header/headerTitleEditor.tsx @@ -1,6 +1,4 @@ import React from 'react'; -import Reflux from 'reflux'; -import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; import {notify} from 'js/utils'; import bem from 'js/bem'; @@ -10,32 +8,43 @@ import {removeInvalidChars, getAssetDisplayName} from 'js/assetUtils'; import { KEY_CODES, NAME_MAX_LENGTH, - ASSET_TYPES + ASSET_TYPES, } from 'js/constants'; +import type {AssetResponse} from 'jsapp/js/dataInterface'; -/** - * @prop {object} asset - * @prop {boolean} isEditable - */ -class HeaderTitleEditor extends React.Component { - constructor(props){ +interface HeaderTitleEditorProps { + asset: AssetResponse; + isEditable: boolean; +} + +interface HeaderTitleEditorState { + name: string; + isPending: boolean; +} + +class HeaderTitleEditor extends React.Component< + HeaderTitleEditorProps, + HeaderTitleEditorState +> { + typingTimer?: NodeJS.Timeout; + + constructor(props: HeaderTitleEditorProps) { super(props); - this.typingTimer = null; this.state = { name: this.props.asset.name, - isPending: false + isPending: false, }; autoBind(this); } componentDidMount() { - this.listenTo(assetStore, this.onAssetLoad); + assetStore.listen(this.onAssetLoad, this); } onAssetLoad() { this.setState({ name: this.props.asset.name, - isPending: false + isPending: false, }); } @@ -54,17 +63,17 @@ class HeaderTitleEditor extends React.Component { } } - assetTitleChange(evt) { + assetTitleChange(evt: React.ChangeEvent) { this.setState({name: removeInvalidChars(evt.target.value)}); clearTimeout(this.typingTimer); this.typingTimer = setTimeout(this.updateAssetTitle.bind(this), 1500); } - assetTitleKeyDown(evt) { + assetTitleKeyDown(evt: React.KeyboardEvent) { if (evt.keyCode === KEY_CODES.ENTER) { clearTimeout(this.typingTimer); if (this.updateAssetTitle()) { - evt.currentTarget.blur(); + evt.currentTarget?.blur(); } } } @@ -115,6 +124,4 @@ class HeaderTitleEditor extends React.Component { } } -reactMixin(HeaderTitleEditor.prototype, Reflux.ListenerMixin); - export default HeaderTitleEditor; diff --git a/jsapp/js/components/header/mainHeader.component.tsx b/jsapp/js/components/header/mainHeader.component.tsx new file mode 100644 index 0000000000..29636ad720 --- /dev/null +++ b/jsapp/js/components/header/mainHeader.component.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import {observer} from 'mobx-react'; +import {stores} from 'js/stores'; +import sessionStore from 'js/stores/session'; +import assetStore from 'js/assetStore'; +import bem, {makeBem} from 'js/bem'; +import { + getLoginUrl, + isAnyFormRoute, + isAnyProjectsViewRoute, + isMyLibraryRoute, + isPublicCollectionsRoute, +} from 'js/router/routerUtils'; +import {getAssetIcon} from 'js/assetUtils'; +import HeaderTitleEditor from 'js/components/header/headerTitleEditor'; +import SearchBox from 'js/components/header/searchBox'; +import myLibraryStore from 'js/components/library/myLibraryStore'; +import {userCan} from 'js/components/permissions/utils'; +import AccountMenu from './accountMenu'; +import type {AssetResponse} from 'js/dataInterface'; +import {withRouter, router} from 'js/router/legacy'; +import type {WithRouterProps} from 'js/router/legacy'; +import Icon from 'js/components/common/icon'; +import type {IconName} from 'jsapp/fonts/k-icons'; + +bem.MainHeader = makeBem(null, 'main-header', 'header'); +bem.MainHeader__icon = makeBem(bem.MainHeader, 'icon'); +bem.MainHeader__title = makeBem(bem.MainHeader, 'title'); +bem.MainHeader__counter = makeBem(bem.MainHeader, 'counter'); + +interface MainHeaderProps extends WithRouterProps { + assetUid: string | null; +} + +const MainHeader = class MainHeader extends React.Component { + private unlisteners: Function[] = []; + + componentDidMount() { + // HACK: re-rendering this every time we navigate is not perfect. We need to + // come up with a better solution. + router!.subscribe(() => this.forceUpdate()); + + // Without much refactor, we ensure that the header re-renders itself, + // whenever any linked store changes. + this.unlisteners.push( + assetStore.listen(() => this.forceUpdate(), this), + myLibraryStore.listen(() => this.forceUpdate(), this) + ); + } + + componentWillUnmount() { + this.unlisteners.forEach((clb) => { + clb(); + }); + } + + isSearchBoxDisabled() { + if (isMyLibraryRoute()) { + // disable search when user has zero assets + return myLibraryStore.getCurrentUserTotalAssets() === null; + } else { + return false; + } + } + + renderLoginButton() { + return ( + + + {t('Log In')} + + + ); + } + + renderGitRevInfo() { + if ( + 'git_rev' in sessionStore.currentAccount && + sessionStore.currentAccount.git_rev.branch && + sessionStore.currentAccount.git_rev.short + ) { + return ( + + + branch: {sessionStore.currentAccount.git_rev.branch} + + + commit: {sessionStore.currentAccount.git_rev.short} + + + ); + } + + return false; + } + + toggleFixedDrawer() { + stores.pageState.toggleFixedDrawer(); + } + + render() { + const isLoggedIn = sessionStore.isLoggedIn; + + let asset: AssetResponse | undefined; + if (this.props.assetUid) { + asset = assetStore.getAsset(this.props.assetUid); + } + + let userCanEditAsset = false; + if (asset) { + userCanEditAsset = userCan('change_asset', asset); + } + + let iconName: IconName | undefined; + if (asset) { + iconName = getAssetIcon(asset); + } + + let librarySearchBoxPlaceholder = t('Search My Library'); + if (isPublicCollectionsRoute()) { + librarySearchBoxPlaceholder = t('Search Public Collections'); + } + + return ( + +
    + {sessionStore.isLoggedIn && ( + + + + )} + + + + + + + + {/* Things for Library */} + {isLoggedIn && (isMyLibraryRoute() || isPublicCollectionsRoute()) && ( +
    + +
    + )} + + {/* Things for My Projects and any Custom View */} + {isLoggedIn && isAnyProjectsViewRoute() && ( +
    + +
    + )} + + {/* Things for Project */} + {asset && isAnyFormRoute() && ( + + {iconName && ( + + + + )} + + + + {asset.has_deployment && ( + + {asset.deployment__submission_count} {t('submissions')} + + )} + + )} + + + + {!isLoggedIn && this.renderLoginButton()} +
    + {this.renderGitRevInfo()} +
    + ); + } +}; + +export default observer(withRouter(MainHeader)); diff --git a/jsapp/js/components/header/searchBox.es6 b/jsapp/js/components/header/searchBox.es6 deleted file mode 100644 index c109a071c2..0000000000 --- a/jsapp/js/components/header/searchBox.es6 +++ /dev/null @@ -1,74 +0,0 @@ -import debounce from 'lodash.debounce'; -import React from 'react'; -import Reflux from 'reflux'; -import reactMixin from 'react-mixin'; -import autoBind from 'react-autobind'; -import bem from 'js/bem'; -import searchBoxStore from './searchBoxStore'; -import {KEY_CODES} from 'js/constants'; - -/** - * @prop {string} placeholder - A text to be displayed in empty input. - * @prop {boolean} disabled - For disabling input. - */ -export default class SearchBox extends React.Component { - constructor(props) { - super(props); - this.state = { - inputVal: searchBoxStore.getSearchPhrase() - }; - this.setSearchPhraseDebounced = debounce(this.setSearchPhrase, 500); - autoBind(this); - } - - componentDidMount() { - this.listenTo(searchBoxStore, this.searchBoxStoreChanged); - } - - searchBoxStoreChanged(store) { - this.setState({inputVal: store.searchPhrase}); - } - - onInputChange(evt) { - const newVal = evt.target.value; - // set `inpuVal` immediately, but update store after some time - // to avoid unnecessary updates while typing - this.setState({inputVal: newVal}); - this.setSearchPhraseDebounced(newVal); - } - - onInputKeyUp(evt) { - if (evt.keyCode === KEY_CODES.ENTER) { - this.setSearchPhrase(evt.target.value.trim()); - } - } - - setSearchPhrase(searchPhrase) { - searchBoxStore.setSearchPhrase(searchPhrase.trim()); - } - - clear() { - searchBoxStore.clear(); - } - - render() { - return ( - - - - {this.state.inputVal !== '' && - - } - - ); - } -} - -reactMixin(SearchBox.prototype, Reflux.ListenerMixin); diff --git a/jsapp/js/components/header/searchBox.tsx b/jsapp/js/components/header/searchBox.tsx new file mode 100644 index 0000000000..0a94f312a0 --- /dev/null +++ b/jsapp/js/components/header/searchBox.tsx @@ -0,0 +1,94 @@ +import debounce from 'lodash.debounce'; +import React from 'react'; +import {observer} from 'mobx-react'; +import bem from 'js/bem'; +import searchBoxStore from './searchBoxStore'; +import {KEY_CODES} from 'js/constants'; +import {autorun} from 'mobx'; + +interface SearchBoxProps { + /** A text to be displayed in empty input. */ + placeholder?: string; + /** For disabling input. */ + disabled?: boolean; +} + +interface SearchBoxState { + inputVal: string; +} + +class SearchBox extends React.Component { + setSearchPhraseDebounced = debounce(this.setSearchPhrase.bind(this), 500); + cancelAutorun?: () => void; + + constructor(props: SearchBoxProps) { + super(props); + this.state = { + inputVal: searchBoxStore.data.searchPhrase || '', + }; + } + + componentDidMount() { + // We use autorun here instead of simply using `observer`, because we can't + // use `searchPhrase` directly inside the input. + this.cancelAutorun = autorun(() => { + this.searchBoxStoreChanged(); + }); + } + + componentWillUnmount() { + if (typeof this.cancelAutorun === 'function') { + this.cancelAutorun(); + } + } + + searchBoxStoreChanged() { + this.setState({inputVal: searchBoxStore.data.searchPhrase || ''}); + } + + onInputChange(evt: React.ChangeEvent) { + const newVal = evt.target.value; + // set `inputVal` immediately, but update store after some time + // to avoid unnecessary updates while typing + this.setState({inputVal: newVal}); + this.setSearchPhraseDebounced(newVal); + } + + onInputKeyUp(evt: React.KeyboardEvent) { + if (evt.keyCode === KEY_CODES.ENTER) { + this.setSearchPhrase(this.state.inputVal); + } + } + + setSearchPhrase(searchPhrase: string) { + searchBoxStore.setSearchPhrase(searchPhrase.trim()); + } + + clear() { + searchBoxStore.setSearchPhrase(''); + } + + render() { + return ( + + + + {this.state.inputVal !== '' && ( + + )} + + ); + } +} + +export default observer(SearchBox); diff --git a/jsapp/js/components/header/searchBoxStore.ts b/jsapp/js/components/header/searchBoxStore.ts index 8fb66f2143..407813f221 100644 --- a/jsapp/js/components/header/searchBoxStore.ts +++ b/jsapp/js/components/header/searchBoxStore.ts @@ -1,89 +1,56 @@ -import Reflux from 'reflux'; -import type {RouterState} from '@remix-run/router'; -import { - getCurrentPath, - isMyLibraryRoute, - isPublicCollectionsRoute, -} from 'js/router/routerUtils'; -import {router} from 'js/router/legacy'; - -const DEFAULT_SEARCH_PHRASE = ''; - -type SearchBoxContextName = 'MY_LIBRARY' | 'PUBLIC_COLLECTIONS'; - -export const SEARCH_CONTEXTS: { - [name in SearchBoxContextName]: SearchBoxContextName; -} = { - MY_LIBRARY: 'MY_LIBRARY', - PUBLIC_COLLECTIONS: 'PUBLIC_COLLECTIONS', -}; +import {makeAutoObservable} from 'mobx'; interface SearchBoxStoreData { - context: SearchBoxContextName | null; - searchPhrase: string; + /** Context ensures that observers will not be triggered unnecessarily. */ + context?: string; + /** + * Keeps the date of last update to the store. We use it to be able to react + * in a more forceful way to store changes. + */ + lastContextUpdateDate?: number; + /** + * Intentionally left unset by default, so reactions are being called when + * the app is initialized. + */ + searchPhrase?: string; } -class SearchBoxStore extends Reflux.Store { - previousPath = getCurrentPath(); - data: SearchBoxStoreData = { - context: null, - searchPhrase: DEFAULT_SEARCH_PHRASE, - }; - - init() { - setTimeout(() => router!.subscribe(this.onRouteChange.bind(this))); - this.resetContext(); - } - - // manages clearing search when switching main routes - onRouteChange(data: RouterState) { - if ( - this.previousPath.split('/')[1] !== data.location.pathname.split('/')[1] - ) { - this.clear(); - } - this.previousPath = data.location.pathname; - - this.resetContext(); - } - - getSearchPhrase() { - return this.data.searchPhrase; +/** + * This store is responsible for storing search phrase. It can receive it from + * different sources, but is built with `SearchBox` component in mind. + * + * It can provide the search phrase for just one receiver at a time. This is + * enforced by the `context` property. + * + * To use it, set it up with `setContext` during receiving (route) component + * initialization. Do it before using the search phrase for any calls. Also + * ensure `SearchBox` component is present and you observe the store changes. + */ +class SearchBoxStore { + data: SearchBoxStoreData = {}; + + constructor() { + makeAutoObservable(this); } - setSearchPhrase(newVal: string) { + /** This method is for the SearchBox component. */ + public setSearchPhrase(newVal: string) { if (this.data.searchPhrase !== newVal) { this.data.searchPhrase = newVal; - this.trigger(this.data); } } - getContext() { - return this.data.context; - } - - resetContext() { - let newContext: SearchBoxContextName | null = null; - - if (isMyLibraryRoute()) { - newContext = 'MY_LIBRARY'; - } else if (isPublicCollectionsRoute()) { - newContext = 'PUBLIC_COLLECTIONS'; - } - - if (this.data.context !== newContext) { - this.data.context = newContext; - this.data.searchPhrase = DEFAULT_SEARCH_PHRASE; - this.trigger(this.data); - } - } - - clear() { - this.setSearchPhrase(DEFAULT_SEARCH_PHRASE); + /** + * This method is for every component interested in using SearchBoxStore. + * When such component loads, it should register itself (take over) with + * unique context id. + */ + public setContext(newContext: string) { + this.data.context = newContext; + this.data.lastContextUpdateDate = Date.now(); + // Changing context resets the search phrase + this.data.searchPhrase = ''; } } -const searchBoxStore = new SearchBoxStore(); -searchBoxStore.init(); - -export default searchBoxStore; +export default new SearchBoxStore(); diff --git a/jsapp/js/components/library/myLibraryStore.ts b/jsapp/js/components/library/myLibraryStore.ts index b21d4f7dc2..59d690ee04 100644 --- a/jsapp/js/components/library/myLibraryStore.ts +++ b/jsapp/js/components/library/myLibraryStore.ts @@ -1,6 +1,6 @@ import debounce from 'lodash.debounce'; import Reflux from 'reflux'; -import searchBoxStore from '../header/searchBoxStore'; +import searchBoxStore from 'js/components/header/searchBoxStore'; import assetUtils from 'js/assetUtils'; import {getCurrentPath, isAnyLibraryRoute} from 'js/router/routerUtils'; import {actions} from 'js/actions'; @@ -19,6 +19,7 @@ import type { SearchAssetsPredefinedParams, } from 'js/dataInterface'; import type {AssetTypeName} from 'js/constants'; +import {reaction} from 'mobx'; interface MyLibraryStoreData { isFetchingData: boolean; @@ -41,9 +42,9 @@ class MyLibraryStore extends Reflux.Store { */ abortFetchData?: Function; previousPath = getCurrentPath(); - previousSearchPhrase = searchBoxStore.getSearchPhrase(); PAGE_SIZE = 100; DEFAULT_ORDER_COLUMN = ASSETS_TABLE_COLUMNS['date-modified']; + searchContext = 'MY_LIBRARY'; isInitialised = false; @@ -66,15 +67,21 @@ class MyLibraryStore extends Reflux.Store { filterValue: null, }; - fetchDataDebounced?: () => void; + fetchDataDebounced?: (needsMetadata?: boolean) => void; init() { this.fetchDataDebounced = debounce(this.fetchData.bind(this), 2500); this.setDefaultColumns(); + // HACK: We add this ugly `setTimeout` to ensure router exists. setTimeout(() => router!.subscribe(this.onRouteChange.bind(this))); - searchBoxStore.listen(this.searchBoxStoreChanged, this); + + reaction( + () => [searchBoxStore.data.context, searchBoxStore.data.searchPhrase], + this.onSearchBoxStoreChanged.bind(this) + ); + actions.library.moveToCollection.completed.listen( this.onMoveToCollectionCompleted.bind(this) ); @@ -133,10 +140,12 @@ class MyLibraryStore extends Reflux.Store { isAnyLibraryRoute() && !this.data.isFetchingData ) { - this.fetchData(true); + // This will indirectly run `fetchData` + searchBoxStore.setContext(this.searchContext); } } + /** Changes the order column to default and remove filtering. */ setDefaultColumns() { this.data.orderColumnId = this.DEFAULT_ORDER_COLUMN.id; this.data.orderValue = this.DEFAULT_ORDER_COLUMN.defaultValue; @@ -148,7 +157,7 @@ class MyLibraryStore extends Reflux.Store { getSearchParams() { const params: SearchAssetsPredefinedParams = { - searchPhrase: searchBoxStore.getSearchPhrase(), + searchPhrase: searchBoxStore.data.searchPhrase, pageSize: this.PAGE_SIZE, page: this.data.currentPage, collectionsFirst: true, @@ -196,32 +205,31 @@ class MyLibraryStore extends Reflux.Store { isAnyLibraryRoute() && !this.data.isFetchingData ) { - this.fetchData(true); + // This will indirectly run `fetchData` + searchBoxStore.setContext(this.searchContext); } else if ( // coming from outside of library (this.previousPath.split('/')[1] !== 'library' || // public-collections is a special case that is kinda in library, but // actually outside of it this.previousPath.startsWith(ROUTES.PUBLIC_COLLECTIONS)) && + // coming into library isAnyLibraryRoute() ) { // refresh data when navigating into library from other place this.setDefaultColumns(); - this.fetchData(true); + // This will indirectly run `fetchData` + searchBoxStore.setContext(this.searchContext); } this.previousPath = data.location.pathname; } - searchBoxStoreChanged() { - if ( - searchBoxStore.getContext() === 'MY_LIBRARY' && - searchBoxStore.getSearchPhrase() !== this.previousSearchPhrase - ) { + onSearchBoxStoreChanged() { + if (searchBoxStore.data.context === this.searchContext) { // reset to first page when search changes this.data.currentPage = 0; this.data.totalPages = null; this.data.totalSearchAssets = null; - this.previousSearchPhrase = searchBoxStore.getSearchPhrase(); this.fetchData(true); } } @@ -244,7 +252,7 @@ class MyLibraryStore extends Reflux.Store { // update total count for the first time and the ones that will get a full count if ( this.data.totalUserAssets === null || - searchBoxStore.getSearchPhrase() === '' + searchBoxStore.data.searchPhrase === '' ) { this.data.totalUserAssets = this.data.totalSearchAssets; } diff --git a/jsapp/js/components/library/ownedCollectionsStore.ts b/jsapp/js/components/library/ownedCollectionsStore.ts index 7b0b911693..51df7daf82 100644 --- a/jsapp/js/components/library/ownedCollectionsStore.ts +++ b/jsapp/js/components/library/ownedCollectionsStore.ts @@ -38,6 +38,8 @@ class OwnedCollectionsStore extends Reflux.Store { actions.resources.deleteAsset.completed.listen(this.onDeleteAssetCompleted.bind(this)); when(() => sessionStore.isLoggedIn, this.startupStore.bind(this)); + + // HACK: We add this ugly `setTimeout` to ensure router exists. setTimeout(() => router!.subscribe(this.startupStore.bind(this))); this.startupStore(); diff --git a/jsapp/js/components/library/publicCollectionsStore.ts b/jsapp/js/components/library/publicCollectionsStore.ts index 7c13ae8a6e..beba8247e7 100644 --- a/jsapp/js/components/library/publicCollectionsStore.ts +++ b/jsapp/js/components/library/publicCollectionsStore.ts @@ -1,8 +1,6 @@ import Reflux from 'reflux'; import type {RouterState} from '@remix-run/router'; -import searchBoxStore, { - SEARCH_CONTEXTS, -} from 'js/components/header/searchBoxStore'; +import searchBoxStore from 'js/components/header/searchBoxStore'; import assetUtils from 'js/assetUtils'; import {getCurrentPath, isPublicCollectionsRoute} from 'js/router/routerUtils'; import {actions} from 'js/actions'; @@ -20,7 +18,9 @@ import type { DeleteAssetResponse, MetadataResponse, AssetSubscriptionsResponse, + SearchAssetsPredefinedParams, } from 'js/dataInterface'; +import {reaction} from 'mobx'; interface PublicCollectionsStoreData { isFetchingData: boolean; @@ -35,17 +35,6 @@ interface PublicCollectionsStoreData { filterValue?: string | null; } -/** search params shared by all searches */ -interface SearchParams { - searchPhrase: string; - pageSize: number; - page: number; - filterProperty?: string | null; - filterValue?: string | null; - metadata?: boolean; - ordering?: string; -} - class PublicCollectionsStore extends Reflux.Store { /** * A method for aborting current XHR fetch request. @@ -53,9 +42,9 @@ class PublicCollectionsStore extends Reflux.Store { */ abortFetchData?: Function; previousPath = getCurrentPath(); - previousSearchPhrase = searchBoxStore.getSearchPhrase(); PAGE_SIZE = 100; DEFAULT_ORDER_COLUMN = ASSETS_TABLE_COLUMNS['date-modified']; + searchContext = 'PUBLIC_COLLECTIONS'; isInitialised = false; @@ -76,8 +65,14 @@ class PublicCollectionsStore extends Reflux.Store { init() { this.setDefaultColumns(); + // HACK: We add this ugly `setTimeout` to ensure router exists. setTimeout(() => router!.subscribe(this.onRouteChange.bind(this))); - searchBoxStore.listen(this.searchBoxStoreChanged.bind(this), this); + + reaction( + () => [searchBoxStore.data.context, searchBoxStore.data.searchPhrase], + this.onSearchBoxStoreChanged.bind(this) + ); + actions.library.searchPublicCollections.started.listen( this.onSearchStarted.bind(this) ); @@ -131,10 +126,12 @@ class PublicCollectionsStore extends Reflux.Store { isPublicCollectionsRoute() && !this.data.isFetchingData ) { - this.fetchData(true); + // This will indirectly run `fetchData` + searchBoxStore.setContext(this.searchContext); } } + /** Changes the order column to default and remove filtering. */ setDefaultColumns() { this.data.orderColumnId = this.DEFAULT_ORDER_COLUMN.id; this.data.orderValue = this.DEFAULT_ORDER_COLUMN.defaultValue; @@ -145,8 +142,8 @@ class PublicCollectionsStore extends Reflux.Store { // methods for handling search and data fetch getSearchParams() { - const params: SearchParams = { - searchPhrase: searchBoxStore.getSearchPhrase(), + const params: SearchAssetsPredefinedParams = { + searchPhrase: searchBoxStore.data.searchPhrase, pageSize: this.PAGE_SIZE, page: this.data.currentPage, }; @@ -154,7 +151,9 @@ class PublicCollectionsStore extends Reflux.Store { if (this.data.filterColumnId) { const filterColumn = ASSETS_TABLE_COLUMNS[this.data.filterColumnId]; params.filterProperty = filterColumn.filterBy; - params.filterValue = this.data.filterValue; + params.filterValue = this.data.filterValue + ? this.data.filterValue + : undefined; } // Surrounds `filterValue` with double quotes to avoid filters that have @@ -196,28 +195,26 @@ class PublicCollectionsStore extends Reflux.Store { isPublicCollectionsRoute() && !this.data.isFetchingData ) { - this.fetchData(true); + // This will indirectly run `fetchData` + searchBoxStore.setContext(this.searchContext); } else if ( this.previousPath.startsWith(ROUTES.PUBLIC_COLLECTIONS) === false && isPublicCollectionsRoute() ) { // refresh data when navigating into public-collections from other place this.setDefaultColumns(); - this.fetchData(true); + // This will indirectly run `fetchData` + searchBoxStore.setContext(this.searchContext); } this.previousPath = data.location.pathname; } - searchBoxStoreChanged() { - if ( - searchBoxStore.getContext() === SEARCH_CONTEXTS.PUBLIC_COLLECTIONS && - searchBoxStore.getSearchPhrase() !== this.previousSearchPhrase - ) { + onSearchBoxStoreChanged() { + if (searchBoxStore.data.context === this.searchContext) { // reset to first page when search changes this.data.currentPage = 0; this.data.totalPages = null; this.data.totalSearchAssets = null; - this.previousSearchPhrase = searchBoxStore.getSearchPhrase(); this.fetchData(true); } } diff --git a/jsapp/js/components/library/singleCollectionStore.ts b/jsapp/js/components/library/singleCollectionStore.ts index b3de78185c..daddf33a3c 100644 --- a/jsapp/js/components/library/singleCollectionStore.ts +++ b/jsapp/js/components/library/singleCollectionStore.ts @@ -75,7 +75,9 @@ class SingleCollectionStore extends Reflux.Store { init() { this.setDefaultColumns(); + // HACK: We add this ugly `setTimeout` to ensure router exists. setTimeout(() => router!.subscribe(this.onRouteChange.bind(this))); + actions.library.moveToCollection.completed.listen( this.onMoveToCollectionCompleted.bind(this) ); diff --git a/jsapp/js/components/modalForms/projectSettings.es6 b/jsapp/js/components/modalForms/projectSettings.es6 index d0c57c32c4..5f1c2fdc32 100644 --- a/jsapp/js/components/modalForms/projectSettings.es6 +++ b/jsapp/js/components/modalForms/projectSettings.es6 @@ -291,28 +291,22 @@ class ProjectSettings extends React.Component { // archive flow isArchivable() { - return this.state.formAsset.has_deployment && this.state.formAsset.deployment__active; + return this.state.formAsset.deployment_status === 'deployed'; } isArchived() { - return this.state.formAsset.has_deployment && !this.state.formAsset.deployment__active; + return this.state.formAsset.deployment_status === 'archived'; } archiveProject(evt) { evt.preventDefault(); - this.archiveAsset(this.state.formAsset.uid, this.onArchiveProjectStarted.bind(this)); - } - - onArchiveProjectStarted() { + this.archiveAsset(this.state.formAsset.uid); this.setState({isAwaitingArchiveCompleted: true}); } unarchiveProject(evt) { evt.preventDefault(); - this.unarchiveAsset(this.state.formAsset.uid, this.onUnarchiveProjectStarted.bind(this)); - } - - onUnarchiveProjectStarted() { + this.unarchiveAsset(this.state.formAsset.uid); this.setState({isAwaitingUnarchiveCompleted: true}); } diff --git a/jsapp/js/components/modals/koboPrompt.tsx b/jsapp/js/components/modals/koboPrompt.tsx index e5afa04506..e47196fa0a 100644 --- a/jsapp/js/components/modals/koboPrompt.tsx +++ b/jsapp/js/components/modals/koboPrompt.tsx @@ -12,6 +12,8 @@ interface KoboPromptButton { color?: ButtonColor; label: string; onClick: () => void; + isDisabled?: boolean; + isPending?: boolean; } const defaultButtonType = 'full'; @@ -65,6 +67,8 @@ export default function KoboPrompt(props: KoboPromptProps) { size='m' label={promptButton.label} onClick={promptButton.onClick} + isDisabled={promptButton.isDisabled} + isPending={promptButton.isPending} /> ))} diff --git a/jsapp/js/components/permissions/utils.ts b/jsapp/js/components/permissions/utils.ts index e5bd498335..b55f369086 100644 --- a/jsapp/js/components/permissions/utils.ts +++ b/jsapp/js/components/permissions/utils.ts @@ -6,6 +6,7 @@ import type {PermissionCodename} from 'js/constants'; import type { AssetResponse, Permission, + ProjectViewAsset, SubmissionResponse, } from 'js/dataInterface'; import {isSelfOwned} from 'jsapp/js/assetUtils'; @@ -44,7 +45,7 @@ function _doesPermMatch( // - merging asset response directly into component state object) export function userCan( permName: PermissionCodename, - asset?: AssetResponse, + asset?: AssetResponse | ProjectViewAsset, partialPermName: PermissionCodename | null = null ) { // Sometimes asset data is not ready yet and we still call the function @@ -55,37 +56,41 @@ export function userCan( // TODO: check out whether any other checks are really needed at this point. // Pay attention if partial permissions work. - const hasEffectiveAccess = asset.effective_permissions?.some( - (effectivePerm) => effectivePerm.codename === permName - ); - if (hasEffectiveAccess) { - return true; + if ('effective_permissions' in asset) { + const hasEffectiveAccess = asset.effective_permissions?.some( + (effectivePerm) => effectivePerm.codename === permName + ); + if (hasEffectiveAccess) { + return true; + } } - if (!asset.permissions) { - return false; - } const currentUsername = sessionStore.currentAccount.username; + // If you own the asset, you can do everything with it if (asset.owner__username === currentUsername) { return true; } - // if permission is granted publicly, then grant it to current user - const anonAccess = asset.permissions.some( - (perm) => - perm.user === buildUserUrl(ANON_USERNAME) && - perm.permission === permConfig.getPermissionByCodename(permName)?.url - ); - if (anonAccess) { - return true; + if ('permissions' in asset) { + // if permission is granted publicly, then grant it to current user + const anonAccess = asset.permissions.some( + (perm) => + perm.user === buildUserUrl(ANON_USERNAME) && + perm.permission === permConfig.getPermissionByCodename(permName)?.url + ); + if (anonAccess) { + return true; + } + + return asset.permissions.some( + (perm) => + perm.user === buildUserUrl(currentUsername) && + _doesPermMatch(perm, permName, partialPermName) + ); } - return asset.permissions.some( - (perm) => - perm.user === buildUserUrl(currentUsername) && - _doesPermMatch(perm, permName, partialPermName) - ); + return false; } export function userCanPartially( diff --git a/jsapp/js/components/processing/singleProcessingStore.ts b/jsapp/js/components/processing/singleProcessingStore.ts index 3d30d3daa8..87f4b8635d 100644 --- a/jsapp/js/components/processing/singleProcessingStore.ts +++ b/jsapp/js/components/processing/singleProcessingStore.ts @@ -169,6 +169,7 @@ class SingleProcessingStore extends Reflux.Store { init() { this.resetProcessingData(); + // HACK: We add this ugly `setTimeout` to ensure router exists. setTimeout(() => router!.subscribe(this.onRouteChange.bind(this))); actions.submissions.getSubmissionByUuid.completed.listen( diff --git a/jsapp/js/components/projectDownloads/exportFetcher.ts b/jsapp/js/components/projectDownloads/exportFetcher.ts index f8b5ebba27..f8c11650df 100644 --- a/jsapp/js/components/projectDownloads/exportFetcher.ts +++ b/jsapp/js/components/projectDownloads/exportFetcher.ts @@ -2,6 +2,32 @@ import random from 'lodash.random'; import {actions} from 'js/actions'; import envStore from 'js/envStore'; +/** + * Exponentially increases the returned time each time this method is being + * called, with some randomness included. You have to handle `callCount` + * increasing yourself. + * + * Returns number in milliseconds. + * + * Note: this function should end up in `utils.ts` or some other more generic + * place. For now I leave it here to avoid circular dependency errors. + */ +export function getExponentialDelayTime(callCount: number) { + // This magic number gives a nice grow for the delays. + const magicFactor = 1.666; + + return Math.round(1000 * Math.max( + envStore.data.min_retry_time, // Bottom limit + Math.min( + envStore.data.max_retry_time, // Top limit + random( + magicFactor ** callCount, + magicFactor ** (callCount + 1) + ) + ) + )); +} + /** * Responsible for handling interval fetch calls. * @@ -22,40 +48,20 @@ export default class ExportFetcher { this.makeIntervalFetchCall(); } - /** - * Exponentially increases the delay each time this method is being called, - * with some randomness included. - * - * @returns number in milliseconds - */ - private getFetchDelay() { - this.callCount += 1; - // This magic number gives a nice grow for the delays. - const magicFactor = 1.666; - - return Math.round(1000 * Math.max( - envStore.data.min_retry_time, // Bottom limit - Math.min( - envStore.data.max_retry_time, // Top limit - random( - magicFactor ** this.callCount, - magicFactor ** (this.callCount + 1) - ) - ) - )); - } - // Starts making fetch calls in a growing randomized interval. private makeIntervalFetchCall() { if (this.timeoutId > 0) { // Make the call if we've already waited. actions.exports.getExport(this.assetUid, this.exportUid); } + + this.callCount += 1; + // Keep the interval alive (can't use `setInterval` with randomized value, // so we use `setTimout` instead). this.timeoutId = window.setTimeout( this.makeIntervalFetchCall.bind(this), - this.getFetchDelay() + getExponentialDelayTime(this.callCount) ); } diff --git a/jsapp/js/components/searchcollectionlist.es6 b/jsapp/js/components/searchcollectionlist.es6 deleted file mode 100644 index f8ff61b2cd..0000000000 --- a/jsapp/js/components/searchcollectionlist.es6 +++ /dev/null @@ -1,317 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import reactMixin from 'react-mixin'; -import autoBind from 'react-autobind'; -import Reflux from 'reflux'; -import {searches} from 'js/searches'; -import mixins from 'js/mixins'; -import {stores} from 'js/stores'; -import sessionStore from 'js/stores/session'; -import {dataInterface} from 'js/dataInterface'; -import bem from 'js/bem'; -import LoadingSpinner from 'js/components/common/loadingSpinner'; -import AssetRow from './assetrow'; -import DocumentTitle from 'react-document-title'; -import Dropzone from 'react-dropzone'; -import {validFileTypes} from 'utils'; -import {redirectToLogin} from 'js/router/routerUtils'; -import { - ASSET_TYPES, - COMMON_QUERIES, - ACCESS_TYPES, - DEPLOYMENT_CATEGORIES, -} from 'js/constants'; - -class SearchCollectionList extends Reflux.Component { - constructor(props) { - super(props); - this.state = { - ownedCollections: [], - fixedHeadings: '', - fixedHeadingsWidth: 'auto', - }; - this.store = stores.selectedAsset; - this.unlisteners = []; - autoBind(this); - } - componentDidMount() { - this.unlisteners.push( - this.searchStore.listen(this.searchChanged) - ); - this.queryCollections(); - } - componentWillUnmount() { - this.unlisteners.forEach((clb) => {clb();}); - } - searchChanged(searchStoreState) { - this.setState(searchStoreState); - if (searchStoreState.searchState === 'done') { - this.queryCollections(); - } - } - queryCollections() { - if (this.props.searchContext.store.filterTags !== COMMON_QUERIES.s) { - dataInterface.getCollections().then((collections) => { - this.setState({ - ownedCollections: collections.results.filter((value) => { - if (value.access_types && value.access_types.includes(ACCESS_TYPES.shared)) { - // TODO: include shared assets with edit (change) permission for current user - // var hasChangePermission = false; - // value.permissions.forEach((perm, index) => { - // if (perm.permission == 'change_asset') - // hasChangePermission = true; - // }); - // return hasChangePermission; - return false; - } else { - return value.access_types && value.access_types.includes(ACCESS_TYPES.owned); - } - }) - }); - }); - } - } - handleScroll(event) { - if (this.props.searchContext.store.filterTags === COMMON_QUERIES.s) { - let offset = $(event.target).children('.asset-list').offset().top; - this.setState({ - fixedHeadings: offset < 30 ? 'fixed-headings' : '', - fixedHeadingsWidth: offset < 30 ? $(event.target).children('.asset-list').width() + 'px' : 'auto', - }); - } - } - - renderAssetRow(resource) { - var currentUsername = sessionStore.currentAccount && sessionStore.currentAccount.username; - var isSelected = stores.selectedAsset.uid === resource.uid; - var ownedCollections = this.state.ownedCollections; - - // for unnamed assets, we try to display first question label - let firstQuestionLabel; - if ( - resource.asset_type !== ASSET_TYPES.survey.id && - resource.name === '' && - resource.summary && - resource.summary.labels && - resource.summary.labels.length > 0 - ) { - firstQuestionLabel = resource.summary.labels[0]; - } - - return ( - - ); - } - renderHeadings() { - return [ - ( - - - {t('My Library')} - - - {this.state.parentName && - - - {this.state.parentName} - - } - - ), - ( - - - {t('Name')} - - - {t('Type')} - - - {t('Owner')} - - - {t('Last Modified')} - - - )]; - } - renderGroupedHeadings() { - return ( - - - {t('Name')} - - - {t('Shared by')} - - - {t('Created')} - - - {t('Last Modified')} - - - {t('Submissions')} - - - ); - } - renderGroupedResults() { - var searchResultsBucket = 'defaultQueryCategorizedResultsLists'; - if (this.state.searchResultsDisplayed) { - searchResultsBucket = 'searchResultsCategorizedResultsLists'; - } - - var results = Object.keys(DEPLOYMENT_CATEGORIES).map( - (category, i) => { - if (this.state[searchResultsBucket][category].length < 1) { - return []; - } - return [ - - {DEPLOYMENT_CATEGORIES[category].label} - , - - - {this.renderGroupedHeadings()} - { - (() => { - return this.state[[searchResultsBucket]][category].map(this.renderAssetRow); - })() - } - - ]; - } - ); - - return results; - } - - render() { - if (!sessionStore.isLoggedIn && sessionStore.isAuthStateKnown) { - redirectToLogin(); - return null; - } - - var s = this.state; - var docTitle = ''; - let display; - if (this.props.searchContext.store.filterTags === COMMON_QUERIES.s) { - display = 'grouped'; - docTitle = t('Projects'); - } else { - display = 'regular'; - docTitle = t('Library'); - } - return ( - - - - { - (() => { - if (display === 'regular') { - return this.renderHeadings(); - } - })() - } - - { - (() => { - if (s.searchResultsDisplayed) { - if (s.searchState === 'loading') { - return (); - } else if (s.searchState === 'done') { - if (s.searchResultsCount === 0) { - return ( - - - {t('Your search returned no results.')} - - - ); - } else if (display === 'grouped') { - return this.renderGroupedResults(); - } else { - return s.searchResultsList.map(this.renderAssetRow); - } - } - } else { - if (s.defaultQueryState === 'loading') { - return (); - } else if (s.defaultQueryState === 'done') { - if (s.defaultQueryCount < 1) { - if (s.defaultQueryFor.assetType === COMMON_QUERIES.s) { - return ( - - - {t('Let\'s get started by creating your first project. Click the New button to create a new form.')} -
    - {t('Advanced users: You can also drag and drop XLSForms here and they will be uploaded and converted to projects.')} -
    -
    -
    - ); - } else { - return ( - - - {t('Let\'s get started by creating your first library question or question block. Click the New button to create a new question or block.')} - - - ); - } - } - - if (display === 'grouped') { - return this.renderGroupedResults(); - } else { - return s.defaultQueryResultsList.map(this.renderAssetRow); - } - } - } - // it shouldn't get to this point - return false; - })() - } -
    -
    - - {t('Drop files to upload')} -
    -
    -
    -
    - ); - } -} - -SearchCollectionList.defaultProps = { - assetRowClass: AssetRow, - searchContext: 'default', -}; - -SearchCollectionList.contextTypes = { - router: PropTypes.object -}; - -reactMixin(SearchCollectionList.prototype, searches.common); -reactMixin(SearchCollectionList.prototype, mixins.clickAssets); -reactMixin(SearchCollectionList.prototype, Reflux.ListenerMixin); -reactMixin(SearchCollectionList.prototype, mixins.droppable); - -export default SearchCollectionList; diff --git a/jsapp/js/components/submissions/columnsHideDropdown.es6 b/jsapp/js/components/submissions/columnsHideDropdown.es6 index 9a61d2f10b..66bf861389 100644 --- a/jsapp/js/components/submissions/columnsHideDropdown.es6 +++ b/jsapp/js/components/submissions/columnsHideDropdown.es6 @@ -1,6 +1,6 @@ import React from 'react'; import autoBind from 'react-autobind'; -import KoboDropdown, {KoboDropdownPlacements} from 'js/components/common/koboDropdown'; +import KoboDropdown from 'js/components/common/koboDropdown'; import ColumnsHideForm from 'js/components/submissions/columnsHideForm'; import './columnsHideDropdown.scss'; @@ -21,7 +21,7 @@ class ColumnsHideDropdown extends React.Component { render() { return ( diff --git a/jsapp/js/components/tagInput.es6 b/jsapp/js/components/tagInput.es6 deleted file mode 100644 index b9d495bb41..0000000000 --- a/jsapp/js/components/tagInput.es6 +++ /dev/null @@ -1,61 +0,0 @@ -// NOTE Plese do not use it! -// -// TODO This component is very specific in usage as it is calling actions itself -// to update the tags. It should be incorporated into assetrow (the only file -// that uses it) with the use of KoboTagsInput. OR it should be left here to die -// as assetrow is going away in future. -// See: https://github.com/kobotoolbox/kpi/issues/2758 - -import React from 'react'; -import autoBind from 'react-autobind'; -import TagsInput from 'react-tagsinput'; -import {actions} from '../actions'; -import {cleanupTags} from 'js/assetUtils'; - -class TagInput extends React.Component { - constructor(props) { - super(props); - this.state = {tags: props.tags, tag: ''}; - autoBind(this); - } - - handleChange(tags) { - var transformed = cleanupTags(tags); - this.setState({tags: transformed}); - - var uid = this.props.uid; - actions.resources.updateAsset(uid, { - tag_string: transformed.join(',') - }); - - } - - handleChangeInput(tag) { - this.setState({tag}); - } - - pasteSplit(data) { - return data.split(',').map((tag) => {return tag.trim();}); - } - - render() { - var inputProps = { - placeholder: t('Add tag(s)') - }; - return ( - - ); - } -} - -export default TagInput; diff --git a/jsapp/js/dataInterface.ts b/jsapp/js/dataInterface.ts index 76933ba668..807017edd0 100644 --- a/jsapp/js/dataInterface.ts +++ b/jsapp/js/dataInterface.ts @@ -6,10 +6,7 @@ */ import {assign} from 'js/utils'; -import { - ROOT_URL, - COMMON_QUERIES, -} from './constants'; +import {ROOT_URL, COMMON_QUERIES} from './constants'; import type {EnvStoreFieldItem, FreeTierDisplay, SocialApp} from 'js/envStore'; import type {LanguageCode} from 'js/components/languages/languagesStore'; import type { @@ -20,7 +17,7 @@ import type { } from 'js/constants'; import type {Json} from './components/common/common.interfaces'; import type {ProjectViewsSettings} from './projects/customViewStore'; -import {FreeTierThresholds} from "js/envStore"; +import type {FreeTierThresholds} from 'js/envStore'; interface AssetsRequestData { q?: string; @@ -65,7 +62,8 @@ interface BulkSubmissionsRequest { submission_ids?: string[]; } -interface BulkSubmissionsValidationStatusRequest extends BulkSubmissionsRequest { +interface BulkSubmissionsValidationStatusRequest + extends BulkSubmissionsRequest { 'validation_status.uid': ValidationStatus; } @@ -77,19 +75,34 @@ interface AssetFileRequest { } export interface CreateImportRequest { - base64Encoded?: string; + base64Encoded?: string | ArrayBuffer | null; name?: string; - destination?: string; totalFiles?: number; + /** Url of the asset that should be replaced with XLSForm */ + destination?: string; + /** Uid of the asset that should be replaced with XLSForm */ assetUid?: string; + /** Causes the imported XLSForm to be added as Library Item */ + library?: boolean; } export interface ImportResponse { + /** The uid of the import (not asset!) */ uid: string; url: string; messages?: { - updated?: Array<{uid: string; kind: string; summary: AssetSummary; owner__username: string}>; - created?: Array<{uid: string; kind: string; summary: AssetSummary; owner__username: string}>; + updated?: Array<{ + uid: string; + kind: string; + summary: AssetSummary; + owner__username: string; + }>; + created?: Array<{ + uid: string; + kind: string; + summary: AssetSummary; + owner__username: string; + }>; error?: string; error_type?: string; }; @@ -120,9 +133,10 @@ export interface PasswordUpdateFailResponse { interface ProcessingResponseData { [questionName: string]: any; _id: number; -}; +} -export interface GetProcessingSubmissionsResponse extends PaginatedResponse {} +export type GetProcessingSubmissionsResponse = + PaginatedResponse; export interface SubmissionAttachment { download_url: string; @@ -139,34 +153,34 @@ export interface SubmissionAttachment { interface SubmissionSupplementalDetails { [questionName: string]: { transcript?: { - languageCode: LanguageCode - value: string - dateCreated: string - dateModified: string - engine?: string - revisions?: { - dateModified: string - engine?: string - languageCode: LanguageCode - value: string - }[] - } + languageCode: LanguageCode; + value: string; + dateCreated: string; + dateModified: string; + engine?: string; + revisions?: Array<{ + dateModified: string; + engine?: string; + languageCode: LanguageCode; + value: string; + }>; + }; translated?: { [languageCode: LanguageCode]: { - languageCode: LanguageCode - value: string - dateCreated: string - dateModified: string - engine?: string - revisions?: { - dateModified: string - engine?: string - languageCode: LanguageCode - value: string - }[] - } - } - } + languageCode: LanguageCode; + value: string; + dateCreated: string; + dateModified: string; + engine?: string; + revisions?: Array<{ + dateModified: string; + engine?: string; + languageCode: LanguageCode; + value: string; + }>; + }; + }; + }; } export interface SubmissionResponse { @@ -178,7 +192,7 @@ export interface SubmissionResponse { _notes: any[]; _status: string; _submission_time: string; - _submitted_by: string|null; + _submitted_by: string | null; _tags: string[]; _uuid: string; _validation_status: object; @@ -284,7 +298,7 @@ export interface SurveyRow { 'kobo--score-choices'?: string; 'kobo--locking-profile'?: string; /** HXL tags. */ - tags: string[] + tags: string[]; } export interface SurveyChoice { @@ -323,7 +337,8 @@ export interface AssetContent { choices?: SurveyChoice[]; settings?: AssetContentSettings | AssetContentSettings[]; translated?: string[]; - translations?: Array; + /** A list of languages. */ + translations?: Array; 'kobo--locking-profiles'?: AssetLockingProfileDefinition[]; } @@ -333,9 +348,9 @@ interface AssetSummary { columns?: string[]; lock_all?: boolean; lock_any?: boolean; - languages?: Array; + languages?: Array; row_count?: number; - default_translation?: string|null; + default_translation?: string | null; /** To be used in a warning about missing or poorly written question names. */ name_quality?: { ok: number; @@ -367,38 +382,38 @@ interface AssetReportStylesKuidNames { } interface AdvancedSubmissionSchema { - type: 'string' | 'object' - $description: string - url?: string - properties?: AdvancedSubmissionSchemaDefinition - additionalProperties?: boolean - required?: string[] - definitions?: {[name: string]: AdvancedSubmissionSchemaDefinition} + type: 'string' | 'object'; + $description: string; + url?: string; + properties?: AdvancedSubmissionSchemaDefinition; + additionalProperties?: boolean; + required?: string[]; + definitions?: {[name: string]: AdvancedSubmissionSchemaDefinition}; } export interface AssetAdvancedFeatures { transcript?: { /** List of question names */ - values?: string[] + values?: string[]; /** List of transcript enabled languages. */ - languages?: string[] - } + languages?: string[]; + }; translation?: { /** List of question names */ - values?: string[] + values?: string[]; /** List of translations enabled languages. */ - languages?: string[] - } + languages?: string[]; + }; } interface AdvancedSubmissionSchemaDefinition { [name: string]: { - type: 'string' | 'object' - description: string - properties?: {[name: string]: {}} - additionalProperties?: boolean - required?: string[] - } + type: 'string' | 'object'; + description: string; + properties?: {[name: string]: {}}; + additionalProperties?: boolean; + required?: string[]; + }; } /** @@ -483,11 +498,11 @@ export interface AssetResponse extends AssetRequestObject { date_created: string; summary: AssetSummary; date_modified: string; - version_id: string|null; - version__content_hash?: string|null; + version_id: string | null; + version__content_hash?: string | null; version_count?: number; has_deployment: boolean; - deployed_version_id: string|null; + deployed_version_id: string | null; analysis_form_json?: any; deployed_versions?: { count: number; @@ -501,7 +516,7 @@ export interface AssetResponse extends AssetRequestObject { date_modified: string; }>; }; - deployment__identifier: string|null; + deployment__identifier: string | null; deployment__links?: { url?: string; single_url?: string; @@ -522,6 +537,7 @@ export interface AssetResponse extends AssetRequestObject { csv?: string; }; deployment__submission_count: number; + deployment_status: 'archived' | 'deployed' | 'draft'; downloads: AssetDownloads; embeds?: Array<{ format: string; @@ -532,7 +548,9 @@ export interface AssetResponse extends AssetRequestObject { uid: string; kind: string; xls_link?: string; - assignable_permissions?: Array; + assignable_permissions?: Array< + AssignablePermission | AssignablePermissionPartial + >; /** * A list of all permissions (their codenames) that current user has in * regards to this asset. It is a sum of permissions assigned directly for @@ -546,7 +564,7 @@ export interface AssetResponse extends AssetRequestObject { }; subscribers_count: number; status: string; - access_types: string[]|null; + access_types: string[] | null; // TODO: think about creating a new interface for asset that is being extended // on frontend. @@ -562,6 +580,7 @@ export interface AssetResponse extends AssetRequestObject { /** This is the asset object returned by project-views endpoint. */ export interface ProjectViewAsset { url: string; + asset_type: AssetTypeName; date_modified: string; date_created: string; date_deployed: string | null; @@ -572,21 +591,13 @@ export interface ProjectViewAsset { owner__name: string; owner__organization: string; uid: string; - kind: string; name: string; settings: AssetSettings; languages: Array; - asset_type: string; - version_id: string; - version_count: number; has_deployment: boolean; - deployed_version_id: string | null; deployment__active: boolean; deployment__submission_count: number; - permissions: string[]; - status: string; - data_sharing: {}; - data: string; + deployment_status: 'archived' | 'deployed' | 'draft'; } export interface AssetsResponse extends PaginatedResponse { @@ -621,7 +632,7 @@ export interface PermissionDefinition { contradictory: string[]; } -export interface PermissionsConfigResponse extends PaginatedResponse {} +export type PermissionsConfigResponse = PaginatedResponse; interface SocialAccount { provider: string; @@ -667,10 +678,11 @@ export interface AccountResponse { [key: string]: Json | ProjectViewsSettings | undefined; }; git_rev: { - short: string; - long: string; - branch: string; - tag: boolean; + // All are either a string or `false` + short: string | boolean; + long: string | boolean; + branch: string | boolean; + tag: string | boolean; }; social_accounts: SocialAccount[]; } @@ -792,46 +804,69 @@ interface ExternalServiceRequestData { password?: string; } +export interface DeploymentResponse { + backend: string; + /** URL */ + identifier: string; + active: boolean; + version_id: string; + asset: AssetResponse; +} + interface DataInterface { patchProfile: (data: AccountRequest) => JQuery.jqXHR; [key: string]: Function; } -const $ajax = (o: {}) => $.ajax(assign({}, {dataType: 'json', method: 'GET'}, o)); +const $ajax = (o: {}) => + $.ajax(assign({}, {dataType: 'json', method: 'GET'}, o)); export const dataInterface: DataInterface = { - getProfile: () => fetch(`${ROOT_URL}/me/`).then((response) => response.json()), // TODO replace selfProfile - selfProfile: (): JQuery.jqXHR => $ajax({url: `${ROOT_URL}/me/`}), + getProfile: () => + fetch(`${ROOT_URL}/me/`).then((response) => response.json()), // TODO replace selfProfile + selfProfile: (): JQuery.jqXHR => + $ajax({url: `${ROOT_URL}/me/`}), - apiToken: (): JQuery.jqXHR<{token: string}> => $ajax({ + apiToken: (): JQuery.jqXHR<{token: string}> => + $ajax({ url: `${ROOT_URL}/token/?format=json`, }), - getUser: (userUrl: string): JQuery.jqXHR => $ajax({ + getUser: (userUrl: string): JQuery.jqXHR => + $ajax({ url: userUrl, }), queryUserExistence: (username: string): JQuery.Promise => { const d = $.Deferred(); $ajax({url: `${ROOT_URL}/api/v2/users/${username}/`}) - .done(() => {d.resolve(username, true);}) - .fail(() => {d.reject(username, false);}); + .done(() => { + d.resolve(username, true); + }) + .fail(() => { + d.reject(username, false); + }); return d.promise(); }, logout: (): JQuery.Promise => { const d = $.Deferred(); - $ajax({url: `${ROOT_URL}/accounts/logout/`, method: 'POST'}).done(d.resolve).fail(function (/*resp, etype, emessage*/) { - // logout request wasn't successful, but may have logged the user out - // querying '/me/' can confirm if we have logged out. - dataInterface.selfProfile().done(function (data: {message?: string}){ - if (data.message === 'user is not logged in') { - d.resolve(data); - } else { - d.reject(data); - } - }).fail(d.fail); - }); + $ajax({url: `${ROOT_URL}/accounts/logout/`, method: 'POST'}) + .done(d.resolve) + .fail(function (/*resp, etype, emessage*/) { + // logout request wasn't successful, but may have logged the user out + // querying '/me/' can confirm if we have logged out. + dataInterface + .selfProfile() + .done(function (data: {message?: string}) { + if (data.message === 'user is not logged in') { + d.resolve(data); + } else { + d.reject(data); + } + }) + .fail(d.fail); + }); return d.promise(); }, @@ -847,15 +882,19 @@ export const dataInterface: DataInterface = { listTemplates(): JQuery.jqXHR { return $ajax({ - url: `${ROOT_URL}/api/v2/assets/` + (COMMON_QUERIES.t ? `?q=${COMMON_QUERIES.t}`: ''), + url: + `${ROOT_URL}/api/v2/assets/` + + (COMMON_QUERIES.t ? `?q=${COMMON_QUERIES.t}` : ''), }); }, - getCollections(params: { - owner?: string; - pageSize?: number; - page?: number; - } = {}): JQuery.jqXHR { + getCollections( + params: { + owner?: string; + pageSize?: number; + page?: number; + } = {} + ): JQuery.jqXHR { let q = COMMON_QUERIES.c; if (params.owner) { q += ` AND owner__username__exact:${params.owner}`; @@ -872,7 +911,9 @@ export const dataInterface: DataInterface = { }); }, - createAssetSnapshot(data: AssetResponse): JQuery.jqXHR { + createAssetSnapshot( + data: AssetResponse + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/asset_snapshots/`, method: 'POST', @@ -898,7 +939,10 @@ export const dataInterface: DataInterface = { }); }, - addExternalService(uid: string, data: ExternalServiceRequestData): JQuery.jqXHR { + addExternalService( + uid: string, + data: ExternalServiceRequestData + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/hooks/`, method: 'POST', @@ -950,7 +994,11 @@ export const dataInterface: DataInterface = { }); }, - retryExternalServiceLog(uid: string, hookUid: string, lid: string): JQuery.jqXHR { + retryExternalServiceLog( + uid: string, + hookUid: string, + lid: string + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/hooks/${hookUid}/logs/${lid}/retry/`, method: 'PATCH', @@ -966,7 +1014,9 @@ export const dataInterface: DataInterface = { if (data.identifiers) { identifierString = `?names=${data.identifiers.join(',')}`; } - if (data.group_by != '') {identifierString += `&split_by=${data.group_by}`;} + if (data.group_by != '') { + identifierString += `&split_by=${data.group_by}`; + } return $ajax({ url: `${ROOT_URL}/api/v2/assets/${data.uid}/reports/${identifierString}`, @@ -983,10 +1033,18 @@ export const dataInterface: DataInterface = { const data: {[key: string]: any} = { clone_from: params.uid, }; - if (params.name) {data.name = params.name;} - if (params.version_id) {data.clone_from_version_id = params.version_id;} - if (params.new_asset_type) {data.asset_type = params.new_asset_type;} - if (params.parent) {data.parent = params.parent;} + if (params.name) { + data.name = params.name; + } + if (params.version_id) { + data.clone_from_version_id = params.version_id; + } + if (params.new_asset_type) { + data.asset_type = params.new_asset_type; + } + if (params.parent) { + data.parent = params.parent; + } return $ajax({ method: 'POST', url: `${ROOT_URL}/api/v2/assets/`, @@ -1015,11 +1073,14 @@ export const dataInterface: DataInterface = { /* * Dynamic data attachments */ - attachToSource(assetUid: string, data: { - source: string; - fields: string[]; - filename: string; - }): JQuery.jqXHR { + attachToSource( + assetUid: string, + data: { + source: string; + fields: string[]; + filename: string; + } + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/paired-data/`, method: 'POST', @@ -1035,10 +1096,13 @@ export const dataInterface: DataInterface = { }); }, - patchSource(attachmentUrl: string, data: { - fields: string; - filename: string; - }): JQuery.jqXHR { + patchSource( + attachmentUrl: string, + data: { + fields: string; + filename: string; + } + ): JQuery.jqXHR { return $ajax({ url: attachmentUrl, method: 'PATCH', @@ -1061,12 +1125,15 @@ export const dataInterface: DataInterface = { }); }, - patchDataSharing(assetUid: string, data: { - data_sharing: { - enabled: boolean; - fields: string[]; - }; - }): JQuery.jqXHR { + patchDataSharing( + assetUid: string, + data: { + data_sharing: { + enabled: boolean; + fields: string[]; + }; + } + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/`, method: 'PATCH', @@ -1143,7 +1210,9 @@ export const dataInterface: DataInterface = { }); }, - subscribeToCollection(assetUrl: string): JQuery.jqXHR { + subscribeToCollection( + assetUrl: string + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/asset_subscriptions/`, data: { @@ -1160,10 +1229,12 @@ export const dataInterface: DataInterface = { asset__uid: uid, }, method: 'GET', - }).then((data) => $ajax({ + }).then((data) => + $ajax({ url: data.results[0].url, method: 'DELETE', - })); + }) + ); }, getImportDetails(params: {uid: string}): JQuery.jqXHR { @@ -1175,7 +1246,9 @@ export const dataInterface: DataInterface = { return $.getJSON(params.url); } else { // limit is for collections children - return $.getJSON(`${ROOT_URL}/api/v2/assets/${params.id}/?limit=${DEFAULT_PAGE_SIZE}`); + return $.getJSON( + `${ROOT_URL}/api/v2/assets/${params.id}/?limit=${DEFAULT_PAGE_SIZE}` + ); } }, @@ -1191,7 +1264,10 @@ export const dataInterface: DataInterface = { }); }, - createAssetExport(assetUid: string, data: ExportSettingSettings): JQuery.jqXHR { + createAssetExport( + assetUid: string, + data: ExportSettingSettings + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/exports/`, method: 'POST', @@ -1254,10 +1330,7 @@ export const dataInterface: DataInterface = { }); }, - deleteExportSetting( - assetUid: string, - settingUid: string - ): JQuery.jqXHR { + deleteExportSetting(assetUid: string, settingUid: string): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/export-settings/${settingUid}/`, method: 'DELETE', @@ -1283,7 +1356,6 @@ export const dataInterface: DataInterface = { }); }, - _searchAssetsWithPredefinedQuery( params: SearchAssetsPredefinedParams, predefinedQuery: string @@ -1368,15 +1440,19 @@ export const dataInterface: DataInterface = { }); }, - searchMyCollectionAssets(params: SearchAssetsPredefinedParams = {}): JQuery.jqXHR { + searchMyCollectionAssets( + params: SearchAssetsPredefinedParams = {} + ): JQuery.jqXHR { return this._searchAssetsWithPredefinedQuery( params, // we only want the currently viewed collection's assets - `${COMMON_QUERIES.qbtc} AND parent__uid:${params.uid}`, + `${COMMON_QUERIES.qbtc} AND parent__uid:${params.uid}` ); }, - searchMyLibraryAssets(params: SearchAssetsPredefinedParams = {}): JQuery.jqXHR { + searchMyLibraryAssets( + params: SearchAssetsPredefinedParams = {} + ): JQuery.jqXHR { // we only want orphans (assets not inside collection) // unless it's a search let query = COMMON_QUERIES.qbtc; @@ -1387,15 +1463,19 @@ export const dataInterface: DataInterface = { return this._searchAssetsWithPredefinedQuery(params, query); }, - searchMyCollectionMetadata(params: SearchAssetsPredefinedParams = {}): JQuery.jqXHR { + searchMyCollectionMetadata( + params: SearchAssetsPredefinedParams = {} + ): JQuery.jqXHR { return this._searchMetadataWithPredefinedQuery( params, // we only want the currently viewed collection's assets - `${COMMON_QUERIES.qbtc} AND parent__uid:${params.uid}`, + `${COMMON_QUERIES.qbtc} AND parent__uid:${params.uid}` ); }, - searchMyLibraryMetadata(params: SearchAssetsPredefinedParams = {}): JQuery.jqXHR { + searchMyLibraryMetadata( + params: SearchAssetsPredefinedParams = {} + ): JQuery.jqXHR { // we only want orphans (assets not inside collection) // unless it's a search let query = COMMON_QUERIES.qbtc; @@ -1406,20 +1486,18 @@ export const dataInterface: DataInterface = { return this._searchMetadataWithPredefinedQuery(params, query); }, - searchPublicCollections(params: SearchAssetsPredefinedParams = {}): JQuery.jqXHR { + searchPublicCollections( + params: SearchAssetsPredefinedParams = {} + ): JQuery.jqXHR { params.status = 'public-discoverable'; - return this._searchAssetsWithPredefinedQuery( - params, - COMMON_QUERIES.c, - ); + return this._searchAssetsWithPredefinedQuery(params, COMMON_QUERIES.c); }, - searchPublicCollectionsMetadata(params: SearchAssetsPredefinedParams = {}): JQuery.jqXHR { + searchPublicCollectionsMetadata( + params: SearchAssetsPredefinedParams = {} + ): JQuery.jqXHR { params.status = 'public-discoverable'; - return this._searchMetadataWithPredefinedQuery( - params, - COMMON_QUERIES.c, - ); + return this._searchMetadataWithPredefinedQuery(params, COMMON_QUERIES.c); }, assetsHash(): JQuery.jqXHR { @@ -1451,11 +1529,14 @@ export const dataInterface: DataInterface = { return $ajax({ url: `${ROOT_URL}/tags/`, method: 'GET', - data: assign({ - // If this number is too big (e.g. 9999) it causes a deadly timeout - // whenever Form Builder displays the aside Library search - limit: 100, - }, data), + data: assign( + { + // If this number is too big (e.g. 9999) it causes a deadly timeout + // whenever Form Builder displays the aside Library search + limit: 100, + }, + data + ), }); }, @@ -1466,7 +1547,10 @@ export const dataInterface: DataInterface = { }); }, - deployAsset(asset: AssetResponse, redeployment: boolean): JQuery.jqXHR { + deployAsset( + asset: AssetResponse, + redeployment: boolean + ): JQuery.jqXHR { const data: { active: boolean; version_id?: string | null; @@ -1485,7 +1569,10 @@ export const dataInterface: DataInterface = { }); }, - setDeploymentActive(params: {asset: AssetResponse; active: boolean}): JQuery.jqXHR { + setDeploymentActive(params: { + asset: AssetResponse; + active: boolean; + }): JQuery.jqXHR { return $ajax({ method: 'PATCH', url: `${params.asset.url}deployment/`, @@ -1517,12 +1604,15 @@ export const dataInterface: DataInterface = { sort: Array<{desc: boolean; id: string}> = [], fields: string[] = [], filter = '' - ): JQuery.jqXHR { + ): JQuery.jqXHR> { const query = `limit=${pageSize}&start=${page}`; let s = '&sort={"_id":-1}'; // default sort let f = ''; if (sort.length) { - s = sort[0].desc === true ? `&sort={"${sort[0].id}":-1}` : `&sort={"${sort[0].id}":1}`; + s = + sort[0].desc === true + ? `&sort={"${sort[0].id}":-1}` + : `&sort={"${sort[0].id}":1}`; } if (fields.length) { f = `&fields=${JSON.stringify(fields)}`; @@ -1534,7 +1624,7 @@ export const dataInterface: DataInterface = { }); }, - getSubmission(uid: string, sid: string): JQuery.jqXHR { + getSubmission(uid: string, sid: string): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/data/${sid}/`, method: 'GET', @@ -1556,10 +1646,12 @@ export const dataInterface: DataInterface = { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/data/bulk/`, method: 'PATCH', - data: {'payload': JSON.stringify({ - submission_ids: submissionIds, - data: data, - })}, + data: { + payload: JSON.stringify({ + submission_ids: submissionIds, + data: data, + }), + }, }); }, @@ -1570,7 +1662,7 @@ export const dataInterface: DataInterface = { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/data/validation_statuses/`, method: 'PATCH', - data: {'payload': JSON.stringify(data)}, + data: {payload: JSON.stringify(data)}, }); }, @@ -1581,7 +1673,7 @@ export const dataInterface: DataInterface = { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/data/validation_statuses/`, method: 'DELETE', - data: {'payload': JSON.stringify(data)}, + data: {payload: JSON.stringify(data)}, }); }, @@ -1597,7 +1689,10 @@ export const dataInterface: DataInterface = { }); }, - removeSubmissionValidationStatus(uid: string, sid: string): JQuery.jqXHR { + removeSubmissionValidationStatus( + uid: string, + sid: string + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/data/${sid}/validation_status/`, method: 'DELETE', @@ -1618,11 +1713,14 @@ export const dataInterface: DataInterface = { }); }, - bulkDeleteSubmissions(uid: string, data: BulkSubmissionsRequest): JQuery.jqXHR { + bulkDeleteSubmissions( + uid: string, + data: BulkSubmissionsRequest + ): JQuery.jqXHR { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${uid}/data/bulk/`, method: 'DELETE', - data: {'payload': JSON.stringify(data)}, + data: {payload: JSON.stringify(data)}, }); }, diff --git a/jsapp/js/dropzone.utils.tsx b/jsapp/js/dropzone.utils.tsx new file mode 100644 index 0000000000..51bd22fc6f --- /dev/null +++ b/jsapp/js/dropzone.utils.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import type {FileWithPreview} from 'react-dropzone'; +import type {CreateImportRequest, ImportResponse} from 'js/dataInterface'; +import {dataInterface} from 'js/dataInterface'; +import {escapeHtml, join, log, notify} from 'js/utils'; +import {MODAL_TYPES} from './constants'; +import {router, routerIsActive} from 'js/router/legacy'; +import {ROUTES} from './router/routerConstants'; +import {stores} from './stores'; +import {getExponentialDelayTime} from 'js/components/projectDownloads/exportFetcher'; + +const IMPORT_FAILED_GENERIC_MESSAGE = t('Import failed'); + +/** + * An internal method for handling a single file import. Its main functionality + * is creating new asset as either a project or a library item. + * + * It uses a promise to get the final status of the import (either "complete" or + * "error"). It waits for the import finish using an exponential interval. + */ +function onImportSingleXLSFormFile( + name: string, + base64Encoded: string | ArrayBuffer | null +) { + const isLibrary = routerIsActive('library'); + + const importPromise = new Promise((resolve, reject) => { + if (!base64Encoded) { + reject(IMPORT_FAILED_GENERIC_MESSAGE); + return; + } + + dataInterface + .createImport({ + name: name, + base64Encoded: base64Encoded, + library: isLibrary, + }) + .done((data: ImportResponse) => { + // After import was created successfully, we start a loop of checking + // the status of it (by calling API). The promise will resolve when it is + // complete. + notify( + t('Your upload is being processed. This may take a few moments.') + ); + + let callCount = 0; + let timeoutId = -1; + + function makeIntervalStatusCheck() { + // Make the first call only after we've already waited once. This + // ensures we don't check for the import status immediately after it + // was created. + if (timeoutId > 0) { + dataInterface + .getImportDetails({uid: data.uid}) + .done((importData: ImportResponse) => { + if (importData.status === 'complete') { + // Stop interval + window.clearTimeout(timeoutId); + + resolve(importData); + } else if ( + importData.status === 'processing' && + callCount === 5 + ) { + notify.warning( + t( + 'Your upload is taking longer than usual. Please get back in few minutes.' + ) + ); + } else if (importData.status === 'error') { + // Stop interval + window.clearTimeout(timeoutId); + + // Gather all useful error information + const errLines = []; + errLines.push(t('Import Failed!')); + if (name) { + errLines.push(Name: {name}); + } + if (importData.messages?.error) { + errLines.push( + + ${importData.messages.error_type}: $ + {escapeHtml(importData.messages.error)} + + ); + } + reject(
    {join(errLines,
    )}
    ); + } + }) + .fail(() => { + // Stop interval + window.clearTimeout(timeoutId); + + reject(IMPORT_FAILED_GENERIC_MESSAGE); + }); + } + + callCount += 1; + + // Keep the interval alive (can't use `setInterval` with randomized + // value, so we use `setTimout` instead). + timeoutId = window.setTimeout( + makeIntervalStatusCheck, + getExponentialDelayTime(callCount) + ); + } + + // start the interval check + makeIntervalStatusCheck(); + }) + .fail(() => { + reject(t('Failed to create import.')); + }); + }); + + // Handle import processing finish scenarios + importPromise.then( + (importData: ImportResponse) => { + notify(t('XLS Import completed')); + + // We navigate into the imported Project when import completes (not in + // Library though) + if (!isLibrary && importData.messages?.created) { + // We have to dig deep for that single asset uid :) + const firstCreated = importData.messages.created[0]; + if (firstCreated?.uid) { + router!.navigate(ROUTES.FORM.replace(':uid', firstCreated.uid)); + } + } + }, + (reason: string) => { + notify.error(reason); + } + ); +} + +/** + * An internal method for handling a file import among multiple files being + * dropped. This one is targeted towards advanced users (as officially we only + * allow importing a single XLSForm file), thus it is a bit rough around + * the edges. + */ +function onImportOneAmongMany( + name: string, + base64Encoded: string | ArrayBuffer | null, + fileIndex: number, + totalFilesInBatch: number +) { + const isLibrary = routerIsActive('library'); + const isLastFileInBatch = fileIndex + 1 === totalFilesInBatch; + + // We open the modal that displays the message with total files count. + stores.pageState.showModal({ + type: MODAL_TYPES.UPLOADING_XLS, + filename: t('## files').replace('##', String(totalFilesInBatch)), + }); + + const params: CreateImportRequest = { + name: name, + base64Encoded: base64Encoded, + totalFiles: totalFilesInBatch, + library: isLibrary, + }; + + dataInterface + .createImport(params) + // we purposefuly don't do anything on `.done` here + .fail((jqxhr: string) => { + log('Failed to create import: ', jqxhr); + notify.error(t('Failed to create import.')); + }) + .always(() => { + // We run this when last file in the batch finishes. Note that this + // doesn't mean that this is last import that finished, as they are being + // run asynchronously. It's not perfect, but we don't care (rough around + // the edges). + if (isLastFileInBatch) { + // After the last import is created, we hide the modal… + stores.pageState.hideModal(); + // …and display a helpful toast + notify.warning( + t( + 'Your uploads are being processed. This may take a few moments. You will need to refresh the page to see them on the list.' + ) + ); + } + }); +} + +/** + * This is a callback function for `Dropzone` component that handles uploading + * multiple XLSForm files. + * + * Note: similar function is available in `mixins.droppable.dropFiles`, but it + * relies heavily on deprecated technologies. + */ +export function dropImportXLSForms( + accepted: FileWithPreview[], + rejected: FileWithPreview[] +) { + accepted.map((file, index) => { + const reader = new FileReader(); + reader.onload = () => { + if (accepted.length === 1) { + onImportSingleXLSFormFile(file.name, reader.result); + } else { + onImportOneAmongMany(file.name, reader.result, index, accepted.length); + } + }; + reader.readAsDataURL(file); + }); + + rejected.every((file) => { + if (file.type && file.name) { + let errMsg = t('Upload error: could not recognize Excel file.'); + errMsg += ` (${t('Uploaded file name: ')} ${file.name})`; + notify.error(errMsg); + return true; + } else { + notify.error(t('Could not recognize the dropped item(s).')); + return false; + } + }); +} diff --git a/jsapp/js/lists/forms.js b/jsapp/js/lists/forms.js deleted file mode 100644 index 2c9c3ca727..0000000000 --- a/jsapp/js/lists/forms.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import reactMixin from 'react-mixin'; -import Reflux from 'reflux'; -import {COMMON_QUERIES} from 'js/constants'; -import {HOME_VIEW} from 'js/projects/projectViews/constants'; -import {searches} from '../searches'; -import mixins from '../mixins'; -import SearchCollectionList from '../components/searchcollectionlist'; -import ViewSwitcher from 'js/projects/projectViews/viewSwitcher'; -import styles from './forms.module.scss'; - -class FormsSearchableList extends React.Component { - constructor(props) { - super(props); - this.state = { - searchContext: searches.getSearchContext('forms', { - filterParams: { - assetType: COMMON_QUERIES.s, - }, - filterTags: COMMON_QUERIES.s, - }) - }; - } - componentDidMount () { - this.searchSemaphore(); - } - render () { - return ( -
    -
    - -
    - -
    - ); - } -} - -FormsSearchableList.contextTypes = { - router: PropTypes.object -}; - -reactMixin(FormsSearchableList.prototype, searches.common); -reactMixin(FormsSearchableList.prototype, mixins.droppable); -reactMixin(FormsSearchableList.prototype, Reflux.ListenerMixin); - -export default FormsSearchableList; diff --git a/jsapp/js/lists/forms.module.scss b/jsapp/js/lists/forms.module.scss deleted file mode 100644 index f94cf99034..0000000000 --- a/jsapp/js/lists/forms.module.scss +++ /dev/null @@ -1,18 +0,0 @@ -@use 'scss/sizes'; - -// NOTE: this whole wrapper is only a TEMP quick and imperfect fix. We need it -// here so the UI behaves as it was supposed to. In upcoming work the whole -// section would be rewritten, so there is no need to be fancy. -.myProjectsWrapper { - height: calc(100% - 64px); - :global .dropzone { - height: 100%; - } - :global .asset-list--fixed-headings .asset-list-sorts { - margin-top: 62px; - } -} - -.myProjectsHeader:not(:empty) { - padding: sizes.$x30 sizes.$x30 0; -} diff --git a/jsapp/js/lists/sidebarForms.es6 b/jsapp/js/lists/sidebarForms.es6 index d15e924bf4..591ed2a61c 100644 --- a/jsapp/js/lists/sidebarForms.es6 +++ b/jsapp/js/lists/sidebarForms.es6 @@ -9,10 +9,7 @@ import bem from 'js/bem'; import LoadingSpinner from 'js/components/common/loadingSpinner'; import {searches} from '../searches'; import {stores} from '../stores'; -import { - COMMON_QUERIES, - DEPLOYMENT_CATEGORIES, -} from 'js/constants'; +import {COMMON_QUERIES, DEPLOYMENT_CATEGORIES} from 'js/constants'; import AssetName from 'js/components/common/assetName'; import {userCan} from 'js/components/permissions/utils'; @@ -20,9 +17,9 @@ class SidebarFormsList extends Reflux.Component { constructor(props) { super(props); const selectedCategories = { - 'Draft': false, - 'Deployed': false, - 'Archived': false, + Draft: false, + Deployed: false, + Archived: false, }; this.state = { selectedCategories: selectedCategories, @@ -31,7 +28,7 @@ class SidebarFormsList extends Reflux.Component { assetType: COMMON_QUERIES.s, }, filterTags: COMMON_QUERIES.s, - }) + }), }; this.store = stores.pageState; autoBind(this); @@ -46,41 +43,41 @@ class SidebarFormsList extends Reflux.Component { this.setState(searchStoreState); } renderMiniAssetRow(asset) { - var href = `/forms/${asset.uid}`; + let href = `/forms/${asset.uid}`; - if (userCan('view_submissions', asset) && asset.has_deployment && asset.deployment__submission_count) { + if ( + userCan('view_submissions', asset) && + asset.has_deployment && + asset.deployment__submission_count + ) { href = href + '/summary'; } else { href = href + '/landing'; } - let classNames = ['form-sidebar__item']; + const classNames = ['form-sidebar__item']; if (asset.uid === this.currentAssetID()) { classNames.push('form-sidebar__item--active'); } return ( - - + + ); } toggleCategory(c) { - return function() { - var selectedCategories = this.state.selectedCategories; - selectedCategories[c] = !selectedCategories[c]; + return function () { + const selectedCategories = this.state.selectedCategories; + selectedCategories[c] = !selectedCategories[c]; this.setState({ selectedCategories: selectedCategories, }); }.bind(this); } render() { - var s = this.state; - var activeItems = 'defaultQueryCategorizedResultsLists'; + const s = this.state; + let activeItems = 'defaultQueryCategorizedResultsLists'; // sync sidebar with main list when it is not a search query, allows for deletes to update the sidebar as well // this is a temporary fix, a proper fix needs to update defaultQueryCategorizedResultsLists when deleting/archiving/cloning @@ -94,66 +91,66 @@ class SidebarFormsList extends Reflux.Component { } if (s.searchState === 'loading' && s.searchString === false) { - return (); + return ; } return ( - { - (() => { - if (s.defaultQueryState === 'loading') { - return (); - } else if (s.defaultQueryState === 'done') { - return Object.keys(DEPLOYMENT_CATEGORIES).map( - (categoryId) => { - var categoryVisible = this.state.selectedCategories[categoryId]; - if (s[activeItems][categoryId].length < 1) { - categoryVisible = false; - } + {(() => { + if (s.defaultQueryState === 'loading') { + return ; + } else if (s.defaultQueryState === 'done') { + return Object.keys(DEPLOYMENT_CATEGORIES).map((categoryId) => { + let categoryVisible = this.state.selectedCategories[categoryId]; + if (s[activeItems][categoryId].length < 1) { + categoryVisible = false; + } - const icon = ['k-icon']; - if (categoryId === DEPLOYMENT_CATEGORIES.Deployed.id) { - icon.push('k-icon-deploy'); - } - if (categoryId === DEPLOYMENT_CATEGORIES.Draft.id) { - icon.push('k-icon-drafts'); - } - if (categoryId === DEPLOYMENT_CATEGORIES.Archived.id) { - icon.push('k-icon-archived'); - } + const icon = ['k-icon']; + if (categoryId === DEPLOYMENT_CATEGORIES.Deployed.id) { + icon.push('k-icon-deploy'); + } + if (categoryId === DEPLOYMENT_CATEGORIES.Draft.id) { + icon.push('k-icon-drafts'); + } + if (categoryId === DEPLOYMENT_CATEGORIES.Archived.id) { + icon.push('k-icon-archived'); + } - return [ - - - - {DEPLOYMENT_CATEGORIES[categoryId].label} - - {s[activeItems][categoryId].length} - , + return [ + + + + {DEPLOYMENT_CATEGORIES[categoryId].label} + + + {s[activeItems][categoryId].length} + + , - - {s[activeItems][categoryId].map(this.renderMiniAssetRow.bind(this))} - - ]; - } - ); - } - })() - } + + {s[activeItems][categoryId].map( + this.renderMiniAssetRow.bind(this) + )} + , + ]; + }); + } + })()} ); } } SidebarFormsList.contextTypes = { - router: PropTypes.object + router: PropTypes.object, }; reactMixin(SidebarFormsList.prototype, searches.common); diff --git a/jsapp/js/mixins.tsx b/jsapp/js/mixins.tsx index 67f0b5b286..d906f95667 100644 --- a/jsapp/js/mixins.tsx +++ b/jsapp/js/mixins.tsx @@ -13,60 +13,100 @@ */ import React from 'react'; -import escape from 'lodash.escape'; import alertify from 'alertifyjs'; -import toast from 'react-hot-toast'; -import assetUtils from 'js/assetUtils'; -import { - PROJECT_SETTINGS_CONTEXTS, - MODAL_TYPES, - ASSET_TYPES, - PERMISSIONS_CODENAMES, -} from './constants'; +import {PROJECT_SETTINGS_CONTEXTS, MODAL_TYPES, ASSET_TYPES} from './constants'; import {ROUTES} from 'js/router/routerConstants'; import {dataInterface} from 'js/dataInterface'; import {stores} from './stores'; import assetStore from 'js/assetStore'; -import sessionStore from 'js/stores/session'; import {actions} from './actions'; -import permConfig from 'js/components/permissions/permConfig'; -import { - log, - assign, - notify, - escapeHtml, - renderCheckbox, - join, -} from 'js/utils'; -import myLibraryStore from 'js/components/library/myLibraryStore'; +import {log, assign, notify, escapeHtml, join} from 'js/utils'; import type { AssetResponse, CreateImportRequest, ImportResponse, - Permission, + DeploymentResponse, } from 'js/dataInterface'; import {getRouteAssetUid} from 'js/router/routerUtils'; import {router, routerGetAssetId, routerIsActive} from 'js/router/legacy'; -import {userCan} from 'js/components/permissions/utils'; +import { + archiveAsset, + unarchiveAsset, + deleteAsset, + cloneAssetAsTemplate, + removeAssetSharing, + deployAsset, +} from 'js/assetQuickActions'; +import type {DropFilesEventHandler} from 'react-dropzone'; const IMPORT_CHECK_INTERVAL = 1000; +interface ApplyImportParams { + destination?: string; + assetUid: string; + name: string; + url?: string; + base64Encoded?: ArrayBuffer | string | null; + lastModified?: number; + totalFiles?: number; +} + +/* + * helper function for apply*ToAsset droppable mixin methods + * returns an interval-driven promise + */ +const applyImport = (params: ApplyImportParams) => { + const applyPromise = new Promise((resolve, reject) => { + actions.resources.createImport(params, (data: ImportResponse) => { + const doneCheckInterval = setInterval(() => { + dataInterface + .getImportDetails({ + uid: data.uid, + }) + .done((importData: ImportResponse) => { + switch (importData.status) { + case 'complete': { + const finalData = + importData.messages?.updated || importData.messages?.created; + if (finalData && finalData.length > 0 && finalData[0].uid) { + clearInterval(doneCheckInterval); + resolve(finalData[0]); + } else { + clearInterval(doneCheckInterval); + reject(importData); + } + break; + } + case 'processing': + case 'created': { + // TODO: notify promise awaiter about delay (after multiple interval rounds) + break; + } + case 'error': + default: { + clearInterval(doneCheckInterval); + reject(importData); + } + } + }) + .fail((failData: ImportResponse) => { + clearInterval(doneCheckInterval); + reject(failData); + }); + }, IMPORT_CHECK_INTERVAL); + }); + }); + return applyPromise; +}; + interface MixinsObject { contextRouter: { [functionName: string]: Function; context?: any; }; - clickAssets: { - onActionButtonClick: Function; - click: { - asset: { - [functionName: string]: Function; - context?: any; - }; - }; - }; droppable: { [functionName: string]: Function; + dropFiles: DropFilesEventHandler; context?: any; props?: any; state?: any; @@ -76,66 +116,41 @@ interface MixinsObject { state?: any; props?: any; }; - cloneAssetAsNewType: { - dialog: Function; - }; } const mixins: MixinsObject = { - contextRouter: {}, - clickAssets: { - onActionButtonClick: Function.prototype, - click: {asset: {}}, - }, - droppable: {}, - dmix: {}, - cloneAssetAsNewType: { - /** Generates dialog when cloning an asset as new type */ - dialog(params: { - sourceUid: string; - sourceName: string; - targetType: string; - promptTitle: string; - promptMessage: string; - }) { + dmix: { + afterCopy() { + notify(t('copied to clipboard')); + }, + + saveCloneAs(evt: React.TouchEvent) { + const version_id = evt.currentTarget.dataset.versionId; + const name = `${t('Clone of')} ${this.state.name}`; + const dialog = alertify.dialog('prompt'); const opts = { - title: params.promptTitle, - message: params.promptMessage, - value: escape(params.sourceName), - labels: {ok: t('Create'), cancel: t('Cancel')}, + title: `${t('Clone')} ${ASSET_TYPES.survey.label}`, + message: t( + 'Enter the name of the cloned ##ASSET_TYPE##. Leave empty to keep the original name.' + ).replace('##ASSET_TYPE##', ASSET_TYPES.survey.label), + value: name, + labels: {ok: t('Ok'), cancel: t('Cancel')}, onok: ({}, value: string) => { - // disable buttons - // NOTE: we need to cast it as HTMLElement because of missing innerText in declaration. - const button1 = (dialog.elements.buttons.primary.children[0] as HTMLElement); - button1.setAttribute('disabled', 'true'); - button1.innerText = t('Please wait…'); - dialog.elements.buttons.primary.children[1].setAttribute('disabled', 'true'); - - actions.resources.cloneAsset({ - uid: params.sourceUid, - name: value, - new_asset_type: params.targetType, - }, { - onComplete: (asset: AssetResponse) => { - dialog.destroy(); - - switch (asset.asset_type) { - case ASSET_TYPES.survey.id: - router!.navigate(ROUTES.FORM_LANDING.replace(':uid', asset.uid)); - break; - case ASSET_TYPES.template.id: - case ASSET_TYPES.block.id: - case ASSET_TYPES.question.id: - router!.navigate(ROUTES.LIBRARY); - break; - } - }, - onFailed: () => { - dialog.destroy(); - notify.error(t('Failed to create new asset!')); + const uid = this.props.params.assetid || this.props.params.uid; + actions.resources.cloneAsset( + { + uid: uid, + name: value, + version_id: version_id, }, - }); + { + onComplete: (asset: AssetResponse) => { + dialog.destroy(); + router!.navigate(`/forms/${asset.uid}`); + }, + } + ); // keep the dialog open return false; @@ -146,778 +161,376 @@ const mixins: MixinsObject = { }; dialog.set(opts).show(); }, - }, -}; - -mixins.dmix = { - afterCopy() { - notify(t('copied to clipboard')); - }, - - saveCloneAs(evt: React.TouchEvent) { - const version_id = evt.currentTarget.dataset.versionId; - const name = `${t('Clone of')} ${this.state.name}`; - - const dialog = alertify.dialog('prompt'); - const opts = { - title: `${t('Clone')} ${ASSET_TYPES.survey.label}`, - message: t('Enter the name of the cloned ##ASSET_TYPE##. Leave empty to keep the original name.').replace('##ASSET_TYPE##', ASSET_TYPES.survey.label), - value: name, - labels: {ok: t('Ok'), cancel: t('Cancel')}, - onok: ({}, value: string) => { - const uid = this.props.params.assetid || this.props.params.uid; - actions.resources.cloneAsset({ - uid: uid, - name: value, - version_id: version_id, - }, { - onComplete: (asset: AssetResponse) => { - dialog.destroy(); - router!.navigate(`/forms/${asset.uid}`); - }, - }); - - // keep the dialog open - return false; - }, - oncancel: () => { - dialog.destroy(); - }, - }; - dialog.set(opts).show(); - }, - - cloneAsTemplate(evt: React.TouchEvent) { - const sourceUid = evt.currentTarget.dataset.assetUid; - const sourceName = evt.currentTarget.dataset.assetName; - mixins.cloneAssetAsNewType.dialog({ - sourceUid: sourceUid, - sourceName: sourceName, - targetType: ASSET_TYPES.template.id, - promptTitle: t('Create new template from this project'), - promptMessage: t('Enter the name of the new template.'), - }); - }, - _deployAssetFirstTime(asset: AssetResponse) { - const deployment_toast = notify.warning(t('deploying to kobocat...'), {duration: 60 * 1000}); - actions.resources.deployAsset(asset, false, { - onDone: () => { - notify(t('deployed form')); - actions.resources.loadAsset({id: asset.uid}); - router!.navigate(`/forms/${asset.uid}`); - toast.dismiss(deployment_toast); - }, - onFail: () => { - toast.dismiss(deployment_toast); - }, - }); - }, - - _redeployAsset(asset: AssetResponse) { - const dialog = alertify.dialog('confirm'); - const opts = { - title: t('Overwrite existing deployment'), - // TODO: Split this into two independent translation strings without HTML - message: t( - 'This form has already been deployed. Are you sure you ' + - 'want overwrite the existing deployment? ' + - '

    This action cannot be undone.' - ), - labels: {ok: t('Ok'), cancel: t('Cancel')}, - onok: () => { - const ok_button = (dialog.elements.buttons.primary.firstChild as HTMLElement); - ok_button.setAttribute('disabled', 'true'); - ok_button.innerText = t('Deploying...'); - actions.resources.deployAsset(asset, true, { - onDone: () => { - notify(t('redeployed form')); - actions.resources.loadAsset({id: asset.uid}); - if (dialog && typeof dialog.destroy === 'function') { - dialog.destroy(); - } - }, - onFail: () => { - if (dialog && typeof dialog.destroy === 'function') { - dialog.destroy(); - } - }, - }); - // keep the dialog open - return false; - }, - oncancel: () => { - dialog.destroy(); - }, - }; - dialog.set(opts).show(); - }, - deployAsset(asset: AssetResponse) { - if (!asset || asset.asset_type !== ASSET_TYPES.survey.id) { + cloneAsTemplate(evt: React.TouchEvent) { + const sourceUid = evt.currentTarget.dataset.assetUid; + const sourceName = evt.currentTarget.dataset.assetName; + if (sourceUid && sourceName) { + cloneAssetAsTemplate(sourceUid, sourceName); + } + }, + deployAsset(asset: AssetResponse) { + if (!asset || asset.asset_type !== ASSET_TYPES.survey.id) { if (this.state && this.state.asset_type === ASSET_TYPES.survey.id) { asset = this.state; } else { - console.error('Neither the arguments nor the state supplied an asset.'); + console.error( + 'Neither the arguments nor the state supplied an asset.' + ); return; } - } - if (!asset.has_deployment) { - this._deployAssetFirstTime(asset); - } else { - this._redeployAsset(asset); - } - }, - archiveAsset(uid: string, callback: Function) { - mixins.clickAssets.click.asset.archive(uid, callback); - }, - unarchiveAsset(uid: string | null = null, callback: Function) { - if (uid === null) { - mixins.clickAssets.click.asset.unarchive(this.state, callback); - } else { - mixins.clickAssets.click.asset.unarchive(uid, callback); - } - }, - deleteAsset(assetOrUid: AssetResponse | string, name: string, callback: Function) { - mixins.clickAssets.click.asset.delete(assetOrUid, name, callback); - }, - toggleDeploymentHistory() { - this.setState({ - historyExpanded: !this.state.historyExpanded, - }); - }, - summaryDetails() { - return ( -
    -        
    -          {this.state.asset_type}
    -          
    - {`[${Object.keys(this.state).join(', ')}]`} -
    - {JSON.stringify(this.state.summary, null, 4)} -
    -
    - ); - }, - asJson(){ - return ( + } + deployAsset(asset); + }, + archiveAsset( + uid: string, + callback: (response: DeploymentResponse) => void + ) { + archiveAsset(uid, callback); + }, + unarchiveAsset( + uid: string | null = null, + callback: (response: DeploymentResponse) => void + ) { + if (uid === null) { + unarchiveAsset(this.state, callback); + } else { + unarchiveAsset(uid, callback); + } + }, + deleteAsset( + assetOrUid: AssetResponse | string, + name: string, + callback: () => void + ) { + deleteAsset(assetOrUid, name, callback); + }, + toggleDeploymentHistory() { + this.setState({ + historyExpanded: !this.state.historyExpanded, + }); + }, + summaryDetails() { + return (
               
    -            {JSON.stringify(this.state, null, 4)}
    +            {this.state.asset_type}
    +            
    + {`[${Object.keys(this.state).join(', ')}]`} +
    + {JSON.stringify(this.state.summary, null, 4)}
    ); - }, - dmixAssetStoreChange(data: {[uid: string]: AssetResponse}) { - const uid = this._getAssetUid(); - const asset = data[uid]; - if (asset) { - this.setState(assign({}, data[uid])); - } - }, - _getAssetUid() { - if (this.props.params) { - return this.props.params.assetid || this.props.params.uid; - } else if (this.props.formAsset) { - // formAsset case is being used strictly for projectSettings component to - // cause the componentDidMount callback to load the full asset (i.e. one - // that includes `content`). - return this.props.formAsset.uid; - } else { - return this.props.uid || getRouteAssetUid(); - } - }, - // TODO 1/2 - // Fix `componentWillUpdate` and `componentDidMount` asset loading flow. - // Ideally we should build a single overaching component or store that would - // handle loading of the asset in all necessary cases in a way that all - // interested parties could use without duplication or confusion and with - // indication when the loading starts and when ends. - componentWillUpdate(newProps: any) { - if ( - this.props.params?.uid !== newProps.params?.uid - ) { - // This case is used by other components (header.es6 is one such component) - // in a not clear way to gain a data on new asset. - actions.resources.loadAsset({id: newProps.params.uid}); - } - }, - - componentDidMount() { - assetStore.listen(this.dmixAssetStoreChange, this); + }, + asJson() { + return ( +
    +          {JSON.stringify(this.state, null, 4)}
    +        
    + ); + }, + dmixAssetStoreChange(data: {[uid: string]: AssetResponse}) { + const uid = this._getAssetUid(); + const asset = data[uid]; + if (asset) { + this.setState(assign({}, data[uid])); + } + }, + _getAssetUid() { + if (this.props.params) { + return this.props.params.assetid || this.props.params.uid; + } else if (this.props.formAsset) { + // formAsset case is being used strictly for projectSettings component to + // cause the componentDidMount callback to load the full asset (i.e. one + // that includes `content`). + return this.props.formAsset.uid; + } else { + return this.props.uid || getRouteAssetUid(); + } + }, + // TODO 1/2 + // Fix `componentWillUpdate` and `componentDidMount` asset loading flow. + // Ideally we should build a single overaching component or store that would + // handle loading of the asset in all necessary cases in a way that all + // interested parties could use without duplication or confusion and with + // indication when the loading starts and when ends. + componentWillUpdate(newProps: any) { + if (this.props.params?.uid !== newProps.params?.uid) { + // This case is used by other components (header.es6 is one such component) + // in a not clear way to gain a data on new asset. + actions.resources.loadAsset({id: newProps.params.uid}); + } + }, - // TODO 2/2 - // HACK FIX: for when we use `PermProtectedRoute`, we don't need to make the - // call to get asset, as it is being already made. Ideally we want to have - // this nice SSOT as described in TODO comment above. - const uid = this._getAssetUid(); - if (uid && this.props.initialAssetLoadNotNeeded) { - this.setState(assign({}, assetStore.data[uid])); - } else if (uid) { - actions.resources.loadAsset({id: uid}); - } - }, + componentDidMount() { + assetStore.listen(this.dmixAssetStoreChange, this); + + // TODO 2/2 + // HACK FIX: for when we use `PermProtectedRoute`, we don't need to make the + // call to get asset, as it is being already made. Ideally we want to have + // this nice SSOT as described in TODO comment above. + const uid = this._getAssetUid(); + if (uid && this.props.initialAssetLoadNotNeeded) { + this.setState(assign({}, assetStore.data[uid])); + } else if (uid) { + actions.resources.loadAsset({id: uid}); + } + }, - removeSharing: function () { - mixins.clickAssets.click.asset.removeSharing(this.props.params.uid); + removeSharing: function () { + removeAssetSharing(this.props.params.uid); + }, }, -}; - -interface ApplyImportParams { - destination?: string; - assetUid: string; - name: string; - url?: string; - base64Encoded?: ArrayBuffer | string | null; - lastModified?: number; - totalFiles?: number; -} + droppable: { + /* + * returns an interval-driven promise + */ + applyFileToAsset(file: File, asset: AssetResponse) { + const applyPromise = new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const params: ApplyImportParams = { + destination: asset.url, + assetUid: asset.uid, + name: file.name, + base64Encoded: reader.result, + lastModified: file.lastModified, + totalFiles: 1, + }; -/* - * helper function for apply*ToAsset droppable mixin methods - * returns an interval-driven promise - */ -const applyImport = (params: ApplyImportParams) => { - const applyPromise = new Promise((resolve, reject) => { - actions.resources.createImport(params, (data: ImportResponse) => { - const doneCheckInterval = setInterval(() => { - dataInterface.getImportDetails({ - uid: data.uid, - }).done((importData: ImportResponse) => { - switch (importData.status) { - case 'complete': { - const finalData = importData.messages?.updated || importData.messages?.created; - if (finalData && finalData.length > 0 && finalData[0].uid) { - clearInterval(doneCheckInterval); - resolve(finalData[0]); - } else { - clearInterval(doneCheckInterval); - reject(importData); - } - break; - } - case 'processing': - case 'created': { - // TODO: notify promise awaiter about delay (after multiple interval rounds) - break; - } - case 'error': - default: { - clearInterval(doneCheckInterval); - reject(importData); + applyImport(params).then( + (data) => { + resolve(data); + }, + (data) => { + reject(data); } - } - }).fail((failData: ImportResponse) => { - clearInterval(doneCheckInterval); - reject(failData); - }); - }, IMPORT_CHECK_INTERVAL); - }); - }); - return applyPromise; -}; + ); + }; + reader.readAsDataURL(file); + }); + return applyPromise; + }, -mixins.droppable = { - /* - * returns an interval-driven promise - */ - applyFileToAsset(file: File, asset: AssetResponse) { - const applyPromise = new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { + /* + * returns an interval-driven promise + */ + applyUrlToAsset(url: string, asset: AssetResponse) { + const applyPromise = new Promise((resolve, reject) => { const params: ApplyImportParams = { destination: asset.url, + url: url, + name: asset.name, assetUid: asset.uid, - name: file.name, - base64Encoded: reader.result, - lastModified: file.lastModified, - totalFiles: 1, }; applyImport(params).then( - (data) => {resolve(data);}, - (data) => {reject(data);} + (data) => { + resolve(data); + }, + (data) => { + reject(data); + } ); - }; - reader.readAsDataURL(file); - }); - return applyPromise; - }, - - /* - * returns an interval-driven promise - */ - applyUrlToAsset(url: string, asset: AssetResponse) { - const applyPromise = new Promise((resolve, reject) => { - const params: ApplyImportParams = { - destination: asset.url, - url: url, - name: asset.name, - assetUid: asset.uid, - }; - - applyImport(params).then( - (data) => {resolve(data);}, - (data) => {reject(data);} - ); - }); - return applyPromise; - }, - - _forEachDroppedFile(params: CreateImportRequest = {}) { - const totalFiles = params.totalFiles || 1; - - const router = this.props.router; - const isProjectReplaceInForm = ( - this.props.context === PROJECT_SETTINGS_CONTEXTS.REPLACE - && routerIsActive('forms') - && router.params.uid !== undefined - ); - const isLibrary = routerIsActive('library'); - const multipleFiles = (params.totalFiles && totalFiles > 1) ? true : false; - params = assign({library: isLibrary}, params); - - if (params.base64Encoded) { - stores.pageState.showModal({ - type: MODAL_TYPES.UPLOADING_XLS, - filename: multipleFiles ? t('## files').replace('##', String(totalFiles)) : params.name, }); - } + return applyPromise; + }, - delete params.totalFiles; + _forEachDroppedFile(params: CreateImportRequest = {}) { + const totalFiles = params.totalFiles || 1; - if (!isLibrary && params.base64Encoded) { - const destination = params.destination || this.state.url; - if (destination) { - params = assign({destination: destination}, params); - } - } + const isLibrary = routerIsActive('library'); + const multipleFiles = params.totalFiles && totalFiles > 1 ? true : false; + params = assign({library: isLibrary}, params); - actions.resources.createImport(params, (data: ImportResponse) => { - // TODO get rid of this barbaric method of waiting a magic number of seconds - // to check if import was done - possibly while doing - // https://github.com/kobotoolbox/kpi/issues/476 - window.setTimeout(() => { - dataInterface.getImportDetails({ - uid: data.uid, - }).done((importData: ImportResponse) => { - if (importData.status === 'complete') { - const assetData = importData.messages?.updated || importData.messages?.created; - const assetUid = assetData && assetData.length > 0 && assetData[0].uid; - if (!isLibrary && multipleFiles) { - this.searchDefault(); - // No message shown for multiple files when successful, to avoid overloading screen - } else if (!assetUid) { - // TODO: use a more specific error message here - notify.error(t('XLSForm Import failed. Check that the XLSForm and/or the URL are valid, and try again using the "Replace form" icon.')); - if (params.assetUid) { - router.navigate(`/forms/${params.assetUid}`); - } - } else { - if (isProjectReplaceInForm) { - actions.resources.loadAsset({id: assetUid}); - } else if (!isLibrary) { - router.navigate(`/forms/${assetUid}`); - } - notify(t('XLS Import completed')); - } - } else if (importData.status === 'processing') { - // If the import task didn't complete immediately, inform the user accordingly. - notify.warning(t('Your upload is being processed. This may take a few moments.')); - } else if (importData.status === 'created') { - notify.warning(t('Your upload is queued for processing. This may take a few moments.')); - } else if (importData.status === 'error') { - const errLines = []; - errLines.push(t('Import Failed!')); - if (params.name) { - errLines.push(Name: {params.name}); - } - if (importData.messages?.error) { - errLines.push(${importData.messages.error_type}: ${escapeHtml(importData.messages.error)}); - } - notify.error(
    {join(errLines,
    )}
    ); - } else { - notify.error(t('Import Failed!')); - } - }).fail((failData: ImportResponse) => { - notify.error(t('Import Failed!')); - log('import failed', failData); + if (params.base64Encoded) { + stores.pageState.showModal({ + type: MODAL_TYPES.UPLOADING_XLS, + filename: multipleFiles + ? t('## files').replace('##', String(totalFiles)) + : params.name, }); - stores.pageState.hideModal(); - }, 2500); - }, (jqxhr: string) => { - log('Failed to create import: ', jqxhr); - notify.error(t('Failed to create import.')); - }); - }, - - dropFiles(files: File[], rejectedFiles: File[], {}, pms = {}) { - files.map((file) => { - const reader = new FileReader(); - reader.onload = () => { - const params = assign({ - name: file.name, - base64Encoded: reader.result, - lastModified: file.lastModified, - totalFiles: files.length, - }, pms); - - this._forEachDroppedFile(params); - }; - reader.readAsDataURL(file); - }); - - for (let i = 0; i < rejectedFiles.length; i++) { - if (rejectedFiles[i].type && rejectedFiles[i].name) { - let errMsg = t('Upload error: could not recognize Excel file.'); - errMsg += ` (${t('Uploaded file name: ')} ${rejectedFiles[i].name})`; - notify.error(errMsg); - } else { - notify.error(t('Could not recognize the dropped item(s).')); - break; } - } - }, -}; - -mixins.clickAssets = { - onActionButtonClick(action: string, uid: string, name: string) { - this.click.asset[action].call(this, uid, name); - }, - click: { - asset: { - clone: function (assetOrUid: AssetResponse | string) { - let asset: AssetResponse; - if (typeof assetOrUid === 'object') { - asset = assetOrUid; - } else { - asset = stores.selectedAsset.asset || stores.allAssets.byUid[assetOrUid]; - } - const assetTypeLabel = ASSET_TYPES[asset.asset_type].label; - - let newName; - const displayName = assetUtils.getAssetDisplayName(asset); - // propose new name only if source asset name is not empty - if (displayName.original) { - newName = `${t('Clone of')} ${displayName.original}`; - } - - const dialog = alertify.dialog('prompt'); - const ok_button = (dialog.elements.buttons.primary.firstChild as HTMLElement); - const opts = { - title: `${t('Clone')} ${assetTypeLabel}`, - message: t('Enter the name of the cloned ##ASSET_TYPE##. Leave empty to keep the original name.').replace('##ASSET_TYPE##', assetTypeLabel), - value: newName, - labels: {ok: t('Ok'), cancel: t('Cancel')}, - onok: ({}, value: string) => { - ok_button.setAttribute('disabled', 'true'); - ok_button.innerText = t('Cloning...'); - let canAddToParent = false; - if (asset.parent) { - const foundParentAsset = myLibraryStore.findAssetByUrl(asset.parent); - canAddToParent = ( - typeof foundParentAsset !== 'undefined' && - userCan(PERMISSIONS_CODENAMES.change_asset, foundParentAsset) - ); - } - - actions.resources.cloneAsset({ - uid: asset.uid, - name: value, - parent: canAddToParent ? asset.parent : undefined, - }, { - onComplete: (asset: AssetResponse) => { - ok_button.removeAttribute('disabled'); - dialog.destroy(); - - // TODO when on collection landing page and user clones this - // collection's child asset, instead of navigating to cloned asset - // landing page, it would be better to stay here and refresh data - // (if the clone will keep the parent asset) - let goToUrl; - if (asset.asset_type === ASSET_TYPES.survey.id) { - goToUrl = `/forms/${asset.uid}/landing`; - } else { - goToUrl = `/library/asset/${asset.uid}`; - } + delete params.totalFiles; - router!.navigate(goToUrl); - notify(t('cloned ##ASSET_TYPE## created').replace('##ASSET_TYPE##', assetTypeLabel)); - }, - }); - // keep the dialog open - return false; - }, - oncancel: () => { - dialog.destroy(); - }, - }; - dialog.set(opts).show(); - }, - cloneAsTemplate: function (sourceUid: string, sourceName: string) { - mixins.cloneAssetAsNewType.dialog({ - sourceUid: sourceUid, - sourceName: sourceName, - targetType: ASSET_TYPES.template.id, - promptTitle: t('Create new template from this project'), - promptMessage: t('Enter the name of the new template.'), - }); - }, - cloneAsSurvey: function (sourceUid: string, sourceName: string) { - mixins.cloneAssetAsNewType.dialog({ - sourceUid: sourceUid, - sourceName: sourceName, - targetType: ASSET_TYPES.survey.id, - promptTitle: t('Create new project from this template'), - promptMessage: t('Enter the name of the new project.'), - }); - }, - edit: function (uid: string) { - if (routerIsActive('library')) { - router!.navigate(`/library/asset/${uid}/edit`); - } else { - router!.navigate(`/forms/${uid}/edit`); - } - }, - delete: function ( - assetOrUid: AssetResponse | string, - name: string, - callback: Function - ) { - let asset: AssetResponse; - if (typeof assetOrUid === 'object') { - asset = assetOrUid; - } else { - asset = stores.selectedAsset.asset || stores.allAssets.byUid[assetOrUid]; + if (!isLibrary && params.base64Encoded) { + const destination = params.destination || this.state.url; + if (destination) { + params = assign({destination: destination}, params); } - const assetTypeLabel = ASSET_TYPES[asset.asset_type].label; - - const safeName = escape(name); - - const dialog = alertify.dialog('confirm'); - const deployed = asset.has_deployment; - let msg; let onshow; - const onok = () => { - actions.resources.deleteAsset({uid: asset.uid, assetType: asset.asset_type}, { - onComplete: () => { - notify(t('##ASSET_TYPE## deleted permanently').replace('##ASSET_TYPE##', assetTypeLabel)); - if (typeof callback === 'function') { - callback(); - } - }, - }); - }; - - if (!deployed) { - if (asset.asset_type !== ASSET_TYPES.survey.id) { - msg = t('You are about to permanently delete this item from your library.'); - } else { - msg = t('You are about to permanently delete this draft.'); - } - } else { - msg = `${t('You are about to permanently delete this form.')}`; - if (asset.deployment__submission_count !== 0) { - msg += `${renderCheckbox('dt1', t('All data gathered for this form will be deleted.'))}`; - } - msg += `${renderCheckbox('dt2', t('The form associated with this project will be deleted.'))} - ${renderCheckbox('dt3', t('I understand that if I delete this project I will not be able to recover it.'), true)} - `; - - onshow = () => { - const ok_button = (dialog.elements.buttons.primary.firstChild as HTMLElement); - ok_button.setAttribute( 'data-cy', 'delete' ); - const $els = $('.alertify-toggle input'); - - ok_button.setAttribute('disabled', 'true'); - $els.each(function () {$(this).prop('checked', false);}); + } - $els.change(function () { - ok_button.removeAttribute('disabled'); - $els.each(function () { - if (!$(this).prop('checked')) { - ok_button.setAttribute('disabled', 'true'); + actions.resources.createImport( + params, + (data: ImportResponse) => { + // TODO get rid of this barbaric method of waiting a magic number of seconds + // to check if import was done - possibly while doing + // https://github.com/kobotoolbox/kpi/issues/476 + window.setTimeout(() => { + dataInterface + .getImportDetails({ + uid: data.uid, + }) + .done((importData: ImportResponse) => { + if (importData.status === 'complete') { + const assetData = + importData.messages?.updated || + importData.messages?.created; + const assetUid = + assetData && assetData.length > 0 && assetData[0].uid; + if (!isLibrary && multipleFiles) { + this.searchDefault(); + // No message shown for multiple files when successful, to avoid overloading screen + } else if (!assetUid) { + // TODO: use a more specific error message here + notify.error( + t( + 'XLSForm Import failed. Check that the XLSForm and/or the URL are valid, and try again using the "Replace form" icon.' + ) + ); + if (params.assetUid) { + router!.navigate(`/forms/${params.assetUid}`); + } + } else { + if ( + this.props.context === PROJECT_SETTINGS_CONTEXTS.REPLACE && + routerIsActive('forms') + ) { + actions.resources.loadAsset({id: assetUid}); + } else if (!isLibrary) { + router!.navigate(`/forms/${assetUid}`); + } + notify(t('XLS Import completed')); + } + } else if (importData.status === 'processing') { + // If the import task didn't complete immediately, inform the user accordingly. + notify.warning( + t( + 'Your upload is being processed. This may take a few moments.' + ) + ); + } else if (importData.status === 'created') { + notify.warning( + t( + 'Your upload is queued for processing. This may take a few moments.' + ) + ); + } else if (importData.status === 'error') { + const errLines = []; + errLines.push(t('Import Failed!')); + if (params.name) { + errLines.push(Name: {params.name}); + } + if (importData.messages?.error) { + errLines.push( + + ${importData.messages.error_type}: $ + {escapeHtml(importData.messages.error)} + + ); + } + notify.error(
    {join(errLines,
    )}
    ); + } else { + notify.error(t('Import Failed!')); } + }) + .fail((failData: ImportResponse) => { + notify.error(t('Import Failed!')); + log('import failed', failData); }); - }); - }; - } - const opts = { - title: `${t('Delete')} ${assetTypeLabel} "${safeName}"`, - message: msg, - labels: { - ok: t('Delete'), - cancel: t('Cancel'), - }, - onshow: onshow, - onok: onok, - oncancel: () => { - dialog.destroy(); - $('.alertify-toggle input').prop('checked', false); - }, - }; - dialog.set(opts).show(); - }, - deploy: function () { - const asset = stores.selectedAsset.asset; - mixins.dmix.deployAsset(asset); - }, - archive: function (assetOrUid: AssetResponse | string, callback: Function) { - let asset: AssetResponse; - if (typeof assetOrUid === 'object') { - asset = assetOrUid; - } else { - asset = stores.selectedAsset.asset || stores.allAssets.byUid[assetOrUid]; - } - const dialog = alertify.dialog('confirm'); - const opts = { - title: t('Archive Project'), - message: `${t('Are you sure you want to archive this project?')}

    - ${t('Your form will not accept submissions while it is archived.')}`, - labels: {ok: t('Archive'), cancel: t('Cancel')}, - onok: () => { - actions.resources.setDeploymentActive({ - asset: asset, - active: false, - }); - if (typeof callback === 'function') { - callback(); - } - }, - oncancel: () => { - dialog.destroy(); - }, - }; - dialog.set(opts).show(); - }, - unarchive: function (assetOrUid: AssetResponse | string, callback: Function) { - let asset: AssetResponse; - if (typeof assetOrUid === 'object') { - asset = assetOrUid; - } else { - asset = stores.selectedAsset.asset || stores.allAssets.byUid[assetOrUid]; + stores.pageState.hideModal(); + }, 2500); + }, + (jqxhr: string) => { + log('Failed to create import: ', jqxhr); + notify.error(t('Failed to create import.')); } - const dialog = alertify.dialog('confirm'); - const opts = { - title: t('Unarchive Project'), - message: `${t('Are you sure you want to unarchive this project?')}`, - labels: {ok: t('Unarchive'), cancel: t('Cancel')}, - onok: () => { - actions.resources.setDeploymentActive({ - asset: asset, - active: true, - }); - if (typeof callback === 'function') { - callback(); - } - }, - oncancel: () => { - dialog.destroy(); - }, - }; - dialog.set(opts).show(); - }, - sharing: function (uid: string) { - stores.pageState.showModal({ - type: MODAL_TYPES.SHARING, - assetid: uid, - }); - }, - refresh: function () { - stores.pageState.showModal({ - type: MODAL_TYPES.REPLACE_PROJECT, - asset: stores.selectedAsset.asset, - }); - }, - translations: function (uid: string) { - stores.pageState.showModal({ - type: MODAL_TYPES.FORM_LANGUAGES, - assetUid: uid, - }); - }, - encryption: function (uid: string) { - stores.pageState.showModal({ - type: MODAL_TYPES.ENCRYPT_FORM, - assetUid: uid, - }); - }, - removeSharing: function (uid: string) { - /** - * Extends `removeAllPermissions` from `userPermissionRow.es6`: - * Checks for permissions from current user before finding correct - * "most basic" permission to remove. - */ - const asset = stores.selectedAsset.asset || stores.allAssets.byUid[uid]; - const userViewAssetPerm = asset.permissions.find((perm: Permission) => { - // Get permissions url related to current user - const permUserUrl = perm.user.split('/'); - return ( - permUserUrl[permUserUrl.length - 2] === sessionStore.currentAccount.username && - perm.permission === permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.view_asset)?.url + ); + }, + + // NOTE: this is a DEPRECATED method of handling Dropzone. Please refer to + // `dropzone.utils.tsx` file and update the code there accordingly to your + // needs. + dropFiles(files: File[], rejectedFiles: File[], {}, pms = {}) { + files.map((file) => { + const reader = new FileReader(); + reader.onload = () => { + const params = assign( + { + name: file.name, + base64Encoded: reader.result, + lastModified: file.lastModified, + totalFiles: files.length, + }, + pms ); - }); - const dialog = alertify.dialog('confirm'); - const opts = { - title: t('Remove shared form'), - message: `${t('Are you sure you want to remove this shared form?')}`, - labels: {ok: t('Remove'), cancel: t('Cancel')}, - onok: () => { - // Only non-owners should have the asset removed from their asset list. - // This menu option is only open to non-owners so we don't need to check again. - const isNonOwner = true; - actions.permissions.removeAssetPermission(uid, userViewAssetPerm.url, isNonOwner); - }, - oncancel: () => { - dialog.destroy(); - }, + this._forEachDroppedFile(params); }; - dialog.set(opts).show(); - }, + reader.readAsDataURL(file); + }); + for (let i = 0; i < rejectedFiles.length; i++) { + if (rejectedFiles[i].type && rejectedFiles[i].name) { + let errMsg = t('Upload error: could not recognize Excel file.'); + errMsg += ` (${t('Uploaded file name: ')} ${rejectedFiles[i].name})`; + notify.error(errMsg); + } else { + notify.error(t('Could not recognize the dropped item(s).')); + break; + } + } }, }, -}; - -mixins.contextRouter = { - isFormList() { - return routerIsActive(ROUTES.FORMS) && this.currentAssetID() === undefined; - }, - isLibrary() { - return routerIsActive(ROUTES.LIBRARY); - }, - isMyLibrary() { - return routerIsActive(ROUTES.MY_LIBRARY); - }, - isPublicCollections() { - return routerIsActive(ROUTES.PUBLIC_COLLECTIONS); - }, - isLibrarySingle() { - return routerIsActive(ROUTES.LIBRARY) && this.currentAssetID() !== undefined; - }, - isFormSingle() { - return routerIsActive(ROUTES.FORMS) && this.currentAssetID() !== undefined; - }, - currentAssetID() { - return routerGetAssetId(); - }, - currentAsset() { - return assetStore.data[this.currentAssetID()]; - }, - isActiveRoute(path: string) { - return routerIsActive(path); - }, - isFormBuilder() { - if (routerIsActive(ROUTES.NEW_LIBRARY_ITEM)) { - return true; - } + contextRouter: { + isFormList() { + return ( + routerIsActive(ROUTES.FORMS) && this.currentAssetID() === undefined + ); + }, + isLibrary() { + return routerIsActive(ROUTES.LIBRARY); + }, + isMyLibrary() { + return routerIsActive(ROUTES.MY_LIBRARY); + }, + isPublicCollections() { + return routerIsActive(ROUTES.PUBLIC_COLLECTIONS); + }, + isLibrarySingle() { + return ( + routerIsActive(ROUTES.LIBRARY) && this.currentAssetID() !== undefined + ); + }, + isFormSingle() { + return ( + routerIsActive(ROUTES.FORMS) && this.currentAssetID() !== undefined + ); + }, + currentAssetID() { + return routerGetAssetId(); + }, + currentAsset() { + return assetStore.data[this.currentAssetID()]; + }, + isActiveRoute(path: string) { + return routerIsActive(path); + }, + isFormBuilder() { + if (routerIsActive(ROUTES.NEW_LIBRARY_ITEM)) { + return true; + } - const uid = this.currentAssetID(); - return ( - uid !== undefined && - routerIsActive(ROUTES.EDIT_LIBRARY_ITEM.replace(':uid', uid)) || - routerIsActive(ROUTES.NEW_LIBRARY_ITEM.replace(':uid', uid)) || - routerIsActive(ROUTES.FORM_EDIT.replace(':uid', uid)) - ); + const uid = this.currentAssetID(); + return ( + (uid !== undefined && + routerIsActive(ROUTES.EDIT_LIBRARY_ITEM.replace(':uid', uid))) || + routerIsActive(ROUTES.NEW_LIBRARY_ITEM.replace(':uid', uid)) || + routerIsActive(ROUTES.FORM_EDIT.replace(':uid', uid)) + ); + }, }, }; diff --git a/jsapp/js/popoverMenu.tsx b/jsapp/js/popoverMenu.tsx index 7aefe2acba..04e64c29c4 100644 --- a/jsapp/js/popoverMenu.tsx +++ b/jsapp/js/popoverMenu.tsx @@ -7,8 +7,14 @@ import autoBind from 'react-autobind'; import bem from 'js/bem'; interface PopoverMenuProps { - popoverSetVisible: () => void; - clearPopover: boolean; + /** A callback run whenever popover is opened (made visible). */ + popoverSetVisible?: () => void; + /** + * This is some weird mechanism for closing the popover from outside. You have + * to pass a `true` value here, and the code observes property changes, and + * would close popover :ironically_impressed_nod:. + */ + clearPopover?: boolean; blurEventDisabled?: boolean; type?: string; additionalModifiers?: string[]; @@ -112,26 +118,6 @@ export default class PopoverMenu extends React.Component< }); } - if (this.props.type === 'assetrow-menu' && !this.state.popoverVisible) { - // if popover doesn't fit above, place it below - // 20px is a nice safety margin - const $assetRow = $(evt.target).parents('.asset-row'); - const $popoverMenu = $(evt.target).parents('.popover-menu').find('.popover-menu__content'); - const rowOffsetTop = $assetRow?.offset()?.top; - const rowHeight = $assetRow?.outerHeight(); - const menuHeight = $popoverMenu?.outerHeight(); - if ( - rowOffsetTop && - rowHeight && - menuHeight && - rowOffsetTop > menuHeight + rowHeight + 20 - ) { - this.setState({placement: 'above'}); - } else { - this.setState({placement: 'below'}); - } - } - if (typeof this.props.popoverSetVisible === 'function' && !this.state.popoverVisible) { this.props.popoverSetVisible(); } diff --git a/jsapp/js/project/projectTopTabs.component.tsx b/jsapp/js/project/projectTopTabs.component.tsx new file mode 100644 index 0000000000..7f779d8645 --- /dev/null +++ b/jsapp/js/project/projectTopTabs.component.tsx @@ -0,0 +1,99 @@ +import classnames from 'classnames'; +import React, {useEffect, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {ROUTES} from 'js/router/routerConstants'; +import {userCan, userCanPartially} from 'js/components/permissions/utils'; +import { + getRouteAssetUid, + isAnyFormDataRoute, + isFormLandingRoute, + isAnyFormSettingsRoute, + isFormSummaryRoute, +} from 'js/router/routerUtils'; +import assetStore from 'js/assetStore'; +import sessionStore from 'js/stores/session'; +import type {AssetResponse} from 'js/dataInterface'; +import styles from './projectTopTabs.module.scss'; + +export default function ProjectTopTabs() { + // First check if uid is available + const assetUid = getRouteAssetUid(); + if (assetUid === null) { + return null; + } + + // Setup navigation + const navigate = useNavigate(); + + const [asset, setAsset] = useState(undefined); + + useEffect(() => { + assetStore.whenLoaded(assetUid, setAsset); + }, []); + + const isDataTabEnabled = + asset?.deployment__identifier != undefined && + asset?.has_deployment && + asset?.deployment__submission_count > 0 && + (userCan('view_submissions', asset) || + userCanPartially('view_submissions', asset)); + + const isSettingsTabEnabled = + sessionStore.isLoggedIn && + (userCan('change_asset', asset) || userCan('change_metadata_asset', asset)); + + return ( + + ); +} diff --git a/jsapp/js/project/projectTopTabs.module.scss b/jsapp/js/project/projectTopTabs.module.scss new file mode 100644 index 0000000000..b5356cca9e --- /dev/null +++ b/jsapp/js/project/projectTopTabs.module.scss @@ -0,0 +1,53 @@ +@use 'scss/sizes'; +@use '~kobo-common/src/styles/colors'; + +@media print { + .root { + display: none; + } +} + +.root { + width: 100%; +} + +.tabs { + background: colors.$kobo-white; + border-bottom: sizes.$x1 solid colors.$kobo-gray-92; + text-align: center; + height: sizes.$x48; + position: relative; + display: flex; + flex-direction: row; + justify-content: center; + align-items: stretch; +} + +.tab { + background: transparent; + border: none; + border-bottom: sizes.$x2 solid transparent; + text-transform: uppercase; + line-height: sizes.$x48; + margin: 0 sizes.$x30; + font-size: sizes.$x15; + cursor: pointer; + position: relative; + color: colors.$kobo-gray-40; + font-weight: normal; + + &:hover, + &.active { + color: colors.$kobo-gray-24; + } + + &.active { + font-weight: 700; + border-bottom: sizes.$x4 solid colors.$kobo-teal; + } + + &.disabled { + pointer-events: none; + opacity: 0.5; + } +} diff --git a/jsapp/js/projects/customViewRoute.tsx b/jsapp/js/projects/customViewRoute.tsx index 91477d291b..a2aa37e6e7 100644 --- a/jsapp/js/projects/customViewRoute.tsx +++ b/jsapp/js/projects/customViewRoute.tsx @@ -2,21 +2,26 @@ import React, {useState, useEffect} from 'react'; import {useParams} from 'react-router-dom'; import {observer} from 'mobx-react-lite'; import {notify, handleApiFail} from 'js/utils'; -import $ from 'jquery'; import type { ProjectsFilterDefinition, ProjectFieldName, } from './projectViews/constants'; import ProjectsFilter from './projectViews/projectsFilter'; import ProjectsFieldsSelector from './projectViews/projectsFieldsSelector'; -import {DEFAULT_PROJECT_FIELDS} from './projectViews/constants'; +import { + DEFAULT_VISIBLE_FIELDS, + DEFAULT_ORDERABLE_FIELDS, +} from './projectViews/constants'; import ViewSwitcher from './projectViews/viewSwitcher'; import ProjectsTable from 'js/projects/projectsTable/projectsTable'; import Button from 'js/components/common/button'; import customViewStore from './customViewStore'; import projectViewsStore from './projectViews/projectViewsStore'; -import styles from './customViewRoute.module.scss'; +import styles from './projectViews.module.scss'; import {toJS} from 'mobx'; +import {ROOT_URL} from 'js/constants'; +import {fetchPostUrl} from 'js/api'; +import ProjectQuickActions from './projectsTable/projectQuickActions'; function CustomViewRoute() { const {viewUid} = useParams(); @@ -27,10 +32,14 @@ function CustomViewRoute() { const [projectViews] = useState(projectViewsStore); const [customView] = useState(customViewStore); + const [selectedRows, setSelectedRows] = useState([]); useEffect(() => { - customView.setUp(viewUid); - customView.fetchAssets(); + customView.setUp( + viewUid, + `${ROOT_URL}/api/v2/project-views/${viewUid}/assets/?`, + DEFAULT_VISIBLE_FIELDS + ); }, [viewUid]); /** Returns a list of names for fields that have at least 1 filter defined. */ @@ -47,20 +56,13 @@ function CustomViewRoute() { const exportAllData = () => { const foundView = projectViews.getView(viewUid); if (foundView) { - $.ajax({ - dataType: 'json', - method: 'POST', - url: foundView.assets_export, - data: {uid: viewUid}, - }) - .done(() => { - notify.warning( - t( - "Export is being generated, you will receive an email when it's done" - ) - ); - }) - .fail(handleApiFail); + fetchPostUrl(foundView.assets_export, {uid: viewUid}).then(() => { + notify.warning( + t( + "Export is being generated, you will receive an email when it's done" + ) + ); + }, handleApiFail); } else { notify.error( t( @@ -70,6 +72,10 @@ function CustomViewRoute() { } }; + const selectedAssets = customView.assets.filter((asset) => + selectedRows.includes(asset.uid) + ); + return (
    @@ -93,18 +99,27 @@ function CustomViewRoute() { label={t('Export all data')} onClick={exportAllData} /> + + {selectedAssets.length === 1 && ( +
    + +
    + )}
    ); diff --git a/jsapp/js/projects/customViewStore.ts b/jsapp/js/projects/customViewStore.ts index 869eeb58f5..dbe8ef43a6 100644 --- a/jsapp/js/projects/customViewStore.ts +++ b/jsapp/js/projects/customViewStore.ts @@ -1,12 +1,14 @@ import $ from 'jquery'; -import {makeAutoObservable} from 'mobx'; +import isEqual from 'lodash.isequal'; +import {makeAutoObservable, reaction} from 'mobx'; import type { + AssetResponse, ProjectViewAsset, PaginatedResponse, FailResponse, } from 'js/dataInterface'; import {handleApiFail} from 'js/utils'; -import {DEFAULT_PROJECT_FIELDS, PROJECT_FIELDS} from './projectViews/constants'; +import {DEFAULT_VISIBLE_FIELDS, PROJECT_FIELDS} from './projectViews/constants'; import type { ProjectFieldName, ProjectsFilterDefinition, @@ -14,17 +16,14 @@ import type { import {buildQueriesFromFilters} from './projectViews/utils'; import type {ProjectsTableOrder} from './projectsTable/projectsTable'; import session from 'js/stores/session'; -import {ROOT_URL} from 'js/constants'; +import searchBoxStore from 'js/components/header/searchBoxStore'; const SAVE_DATA_NAME = 'project_views_settings'; const PAGE_SIZE = 50; const DEFAULT_VIEW_SETTINGS: ViewSettings = { filters: [], - order: { - fieldName: PROJECT_FIELDS.name.name, - direction: 'ascending', - }, + order: {}, // When fields are `undefined`, it means the deafult fields (from // `DEFAULT_PROJECT_FIELDS`) are being used. fields: undefined, @@ -46,26 +45,67 @@ class CustomViewStore { public filters: ProjectsFilterDefinition[] = DEFAULT_VIEW_SETTINGS.filters; public order: ProjectsTableOrder = DEFAULT_VIEW_SETTINGS.order; public fields?: ProjectFieldName[] = DEFAULT_VIEW_SETTINGS.fields; - /** Whether the first call was made. */ + /** Whether the first page call was made after setup. */ public isFirstLoadComplete = false; public isLoading = false; + /** This starts with some default value, but `setUp` overrides it. */ + public defaultVisibleFields: ProjectFieldName[] = DEFAULT_VISIBLE_FIELDS; + /** + * Please pass url with query parameters included, or simply ending with `?`. + * This is the API url we want to call for given view. We have it here, so + * that store would be able to handling both Project Views and My Projects + * routes (as both of them use different APIs with same functionalities + * available) + */ + private baseUrl?: string; private viewUid?: string; /** We use `null` here because the endpoint uses it. */ private nextPageUrl: string | null = null; private ongoingFetch?: JQuery.jqXHR; + private searchContext?: string; constructor() { makeAutoObservable(this); + + // Observe changes to searchBoxStore + reaction( + () => [ + searchBoxStore.data.context, + searchBoxStore.data.searchPhrase, + searchBoxStore.data.lastContextUpdateDate, + ], + () => { + // We are only interested in changes within current context. + if (searchBoxStore.data.context === this.searchContext) { + this.fetchAssets(); + } + } + ); } - /** Use this whenever you need to change the view */ - public setUp(viewUid: string) { + /** + * NOTE: this causes `fetchAssets` to be called because we set new context for + * `searchBoxStore` and it triggers a reaction. + * + * Use this method whenever you change view. + */ + public setUp( + viewUid: string, + baseUrl: string, + defaultVisibleFields: ProjectFieldName[] + ) { this.viewUid = viewUid; + this.baseUrl = baseUrl; + this.defaultVisibleFields = defaultVisibleFields; this.assets = []; this.isFirstLoadComplete = false; this.isLoading = false; this.nextPageUrl = null; this.loadSettings(); + + this.searchContext = viewUid; + // set up search box and trigger indirect fetch of new assets. + searchBoxStore.setContext(viewUid); } /** If next page of results is available. */ @@ -96,7 +136,7 @@ class CustomViewStore { public hideField(fieldName: ProjectFieldName) { let newFields = Array.isArray(this.fields) ? Array.from(this.fields) - : DEFAULT_PROJECT_FIELDS; + : this.defaultVisibleFields; newFields = newFields.filter((item) => item !== fieldName); this.setFields(newFields); } @@ -106,7 +146,12 @@ class CustomViewStore { * > `-name` is descending and `name` is ascending */ private getOrderQuery() { - const fieldDefinition = PROJECT_FIELDS[this.order.fieldName]; + if (!this.order?.fieldName) { + return null; + } + + const fieldDefinition = PROJECT_FIELDS[this.order.fieldName as ProjectFieldName]; + if (this.order.direction === 'descending') { return `-${fieldDefinition.apiOrderingName}`; } @@ -121,7 +166,11 @@ class CustomViewStore { this.isFirstLoadComplete = false; this.isLoading = true; this.assets = []; - const queriesString = buildQueriesFromFilters(this.filters).join(' AND '); + const queries = buildQueriesFromFilters(this.filters); + if (searchBoxStore.data.searchPhrase !== '') { + queries.push(`"${searchBoxStore.data.searchPhrase}"`); + } + const queriesString = queries.join(' AND '); const orderingString = this.getOrderQuery(); if (this.ongoingFetch) { @@ -131,7 +180,8 @@ class CustomViewStore { dataType: 'json', method: 'GET', url: - `${ROOT_URL}/api/v2/project-views/${this.viewUid}/assets/?ordering=${orderingString}&limit=${PAGE_SIZE}` + + `${this.baseUrl}&limit=${PAGE_SIZE}` + + (orderingString ? `&ordering=${orderingString}` : '') + (queriesString ? `&q=${queriesString}` : ''), }) .done(this.onFetchAssetsDone.bind(this)) @@ -154,6 +204,60 @@ class CustomViewStore { } } + public handleAssetChanged(modifiedAsset: AssetResponse) { + const originalAsset = this.assets.find( + (asset: ProjectViewAsset) => modifiedAsset.uid === asset.uid + ); + + // Step 1: check if the asset is on the laoded list + if (!originalAsset) { + return; + } + + // Step 2: check if any data that is being used by the table changed + if ( + originalAsset.name !== modifiedAsset.name || + originalAsset.settings.description !== + modifiedAsset.settings.description || + // Asset status consists of two properties + originalAsset.has_deployment !== modifiedAsset.has_deployment || + originalAsset.deployment__active !== modifiedAsset.deployment__active || + // Owner information is also multiple props, but there is no way + // of knowing whether `owner__email`, `owner__name` (full name), or + // `owner__organization` have changed. Those pieces of information rarely + // change, so there is no need to care about them here. + originalAsset.owner !== modifiedAsset.owner || + originalAsset.owner__username !== modifiedAsset.owner__username || + originalAsset.date_modified !== modifiedAsset.date_modified || + // Date deployed is calculated for `ProjectViewAsset`, but for + // `Asset Response` we need to find the last deployed version + originalAsset.date_deployed !== + modifiedAsset.deployed_versions?.results[0].date_modified || + originalAsset.settings.sector?.value !== + modifiedAsset.settings.sector?.value || + !isEqual( + originalAsset.settings.country, + modifiedAsset.settings.country + ) || + !isEqual(originalAsset.languages, modifiedAsset.summary.languages) || + originalAsset.deployment__submission_count !== + modifiedAsset.deployment__submission_count + ) { + // At this point we know that one of the assets that was being displayed + // on the list changed an important piece of data. We need to fetch data + // again + this.fetchAssets(); + } + } + + public handleAssetsDeleted(deletedAssetsUids: string[]) { + // When asset is deleted, we simply remove it from loaded assets list as it + // seems there is no need to fetch all the data again + this.assets = this.assets.filter( + (asset: ProjectViewAsset) => !deletedAssetsUids.includes(asset.uid) + ); + } + private onFetchAssetsDone(response: PaginatedResponse) { this.isFirstLoadComplete = true; this.isLoading = false; @@ -230,10 +334,10 @@ class CustomViewStore { if (savedViewData.filters) { this.filters = savedViewData.filters; } - if (savedViewData.order) { + if (savedViewData.order?.direction && savedViewData.order?.fieldName) { this.order = savedViewData.order; } - if (savedViewData.fields) { + if (savedViewData.fields && Array.isArray(savedViewData.fields)) { this.fields = savedViewData.fields; } } diff --git a/jsapp/js/projects/myProjectsRoute.module.scss b/jsapp/js/projects/myProjectsRoute.module.scss new file mode 100644 index 0000000000..8c2701d4b2 --- /dev/null +++ b/jsapp/js/projects/myProjectsRoute.module.scss @@ -0,0 +1,40 @@ +@use 'scss/z-indexes'; +@use 'scss/mixins'; +@use 'scss/sizes'; +@use '~kobo-common/src/styles/colors'; +@use 'sass:color'; + +.dropzone { + width: 100%; + height: 100%; + position: relative; +} + +.dropzoneOverlay { + display: none; +} + +.dropzoneActive .dropzoneOverlay { + @include mixins.centerRowFlex; + justify-content: center; + flex-wrap: wrap; + text-align: center; + background-color: color.change(colors.$kobo-white, $alpha: 0.5); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: z-indexes.$z-dropzone; + color: colors.$kobo-blue; + border: 6px dashed currentcolor; + + :global { + h1 { + width: 100%; + margin: sizes.$x6 0 0; + font-size: sizes.$x18; + font-weight: normal; + } + } +} diff --git a/jsapp/js/projects/myProjectsRoute.tsx b/jsapp/js/projects/myProjectsRoute.tsx index 0e697994ce..6e01cd445a 100644 --- a/jsapp/js/projects/myProjectsRoute.tsx +++ b/jsapp/js/projects/myProjectsRoute.tsx @@ -1,12 +1,126 @@ -import React from 'react'; -import {HOME_VIEW} from './projectViews/constants'; +import React, {useState, useEffect} from 'react'; +import {observer} from 'mobx-react-lite'; +import type { + ProjectsFilterDefinition, + ProjectFieldName, +} from './projectViews/constants'; +import ProjectsFilter from './projectViews/projectsFilter'; +import ProjectsFieldsSelector from './projectViews/projectsFieldsSelector'; +import { + HOME_VIEW, + HOME_ORDERABLE_FIELDS, + HOME_DEFAULT_VISIBLE_FIELDS, + HOME_EXCLUDED_FIELDS, +} from './projectViews/constants'; import ViewSwitcher from './projectViews/viewSwitcher'; +import ProjectsTable from 'js/projects/projectsTable/projectsTable'; +import customViewStore from './customViewStore'; +import styles from './projectViews.module.scss'; +import routeStyles from './myProjectsRoute.module.scss'; +import {toJS} from 'mobx'; +import {COMMON_QUERIES, ROOT_URL} from 'js/constants'; +import ProjectQuickActions from './projectsTable/projectQuickActions'; +import ProjectBulkActions from './projectsTable/projectBulkActions'; +import Dropzone from 'react-dropzone'; +import {validFileTypes} from 'js/utils'; +import Icon from 'js/components/common/icon'; +import {dropImportXLSForms} from 'js/dropzone.utils'; + +function MyProjectsRoute() { + const [customView] = useState(customViewStore); + const [selectedRows, setSelectedRows] = useState([]); + + useEffect(() => { + customView.setUp( + HOME_VIEW.uid, + `${ROOT_URL}/api/v2/assets/?q=${COMMON_QUERIES.s}`, + HOME_DEFAULT_VISIBLE_FIELDS + ); + }, []); + + /** Returns a list of names for fields that have at least 1 filter defined. */ + const getFilteredFieldsNames = () => { + const outcome: ProjectFieldName[] = []; + customView.filters.forEach((item: ProjectsFilterDefinition) => { + if (item.fieldName !== undefined) { + outcome.push(item.fieldName); + } + }); + return outcome; + }; + + const selectedAssets = customView.assets.filter((asset) => + selectedRows.includes(asset.uid) + ); + + /** Filters out excluded fields */ + const getTableVisibleFields = () => { + const outcome = toJS(customView.fields) || customView.defaultVisibleFields; + return outcome.filter( + (fieldName) => !HOME_EXCLUDED_FIELDS.includes(fieldName) + ); + }; -export default function MyProjectsRoute() { return ( -
    - - TBD My Projects -
    + +
    + +

    {t('Drop files to upload')}

    +
    + +
    +
    + + + + + + + {selectedAssets.length === 1 && ( +
    + +
    + )} + + {selectedAssets.length > 1 && ( +
    + +
    + )} +
    + + +
    +
    ); } + +export default observer(MyProjectsRoute); diff --git a/jsapp/js/projects/customViewRoute.module.scss b/jsapp/js/projects/projectViews.module.scss similarity index 71% rename from jsapp/js/projects/customViewRoute.module.scss rename to jsapp/js/projects/projectViews.module.scss index 5765824d9c..70aa0a175b 100644 --- a/jsapp/js/projects/customViewRoute.module.scss +++ b/jsapp/js/projects/projectViews.module.scss @@ -12,3 +12,9 @@ gap: sizes.$x30; padding: sizes.$x30 sizes.$x30 sizes.$x40; } + +.actions { + @include mixins.centerRowFlex; + flex: 1; + justify-content: flex-end; +} diff --git a/jsapp/js/projects/projectViews/constants.ts b/jsapp/js/projects/projectViews/constants.ts index 9c97e5fd19..03f2b16339 100644 --- a/jsapp/js/projects/projectViews/constants.ts +++ b/jsapp/js/projects/projectViews/constants.ts @@ -119,8 +119,6 @@ export interface ProjectFieldDefinition { apiOrderingName: string; /** Some of the fields (e.g. `submission`) doesn't allow any filtering yet. */ availableConditions: FilterConditionName[]; - /** Some of the fields (e.g. `submission`) doesn't allow being ordered by. */ - orderable: boolean; } type ProjectFields = {[P in ProjectFieldName]: ProjectFieldDefinition}; @@ -147,7 +145,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNot', 'startsWith', ], - orderable: true, }, description: { name: 'description', @@ -164,19 +161,20 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNotEmpty', 'startsWith', ], - orderable: true, }, status: { name: 'status', label: t('Status'), - apiFilteringName: '_deployment_data__active', - apiOrderingName: '_deployment_data__active', - availableConditions: [], - orderable: true, + apiFilteringName: '_deployment_status', + apiOrderingName: '_deployment_status', + availableConditions: [ + 'is', + 'isNot', + ], }, ownerUsername: { name: 'ownerUsername', - label: t('Owner username'), + label: t('Owner'), apiFilteringName: 'owner__username', apiOrderingName: 'owner__username', availableConditions: [ @@ -187,11 +185,10 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNot', 'startsWith', ], - orderable: true, }, ownerFullName: { name: 'ownerFullName', - label: t('Owner full name'), + label: t('Owner name'), apiFilteringName: 'owner__extra_details__data__name', apiOrderingName: 'owner__extra_details__data__name', availableConditions: [ @@ -204,7 +201,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNotEmpty', 'startsWith', ], - orderable: true, }, ownerEmail: { name: 'ownerEmail', @@ -221,7 +217,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNotEmpty', 'startsWith', ], - orderable: true, }, ownerOrganization: { name: 'ownerOrganization', @@ -238,7 +233,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNotEmpty', 'startsWith', ], - orderable: true, }, dateModified: { name: 'dateModified', @@ -251,7 +245,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'endsWith', 'startsWith', ], - orderable: true, }, dateDeployed: { name: 'dateDeployed', @@ -264,7 +257,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'endsWith', 'startsWith', ], - orderable: true, }, sector: { name: 'sector', @@ -277,7 +269,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'isEmptyObject', 'isNotEmptyObject', ], - orderable: true, }, countries: { name: 'countries', @@ -292,7 +283,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNot', 'isNotEmptyObject', ], - orderable: false, }, languages: { name: 'languages', @@ -307,7 +297,6 @@ export const PROJECT_FIELDS: ProjectFields = { 'isNot', 'isNotEmptyObject', ], - orderable: false, }, submissions: { name: 'submissions', @@ -315,11 +304,43 @@ export const PROJECT_FIELDS: ProjectFields = { apiFilteringName: 'deployment__submission_count', apiOrderingName: 'deployment__submission_count', availableConditions: [], - orderable: false, }, }; -export const DEFAULT_PROJECT_FIELDS: ProjectFieldName[] = [ +/** + * The fields that the `/api/v2/project-views//assets/` endpoint is able + * to order the data by. AKA the default orderable fields. + */ +export const DEFAULT_ORDERABLE_FIELDS: ProjectFieldName[] = [ + 'dateDeployed', + 'dateModified', + 'description', + 'name', + 'ownerEmail', + 'ownerFullName', + 'ownerOrganization', + 'ownerUsername', + 'sector', + 'status', +]; + +/** + * The fields that the `/api/v2/assets/` endpoint can order the data by. AKA + * the orderable fields for the "My Projects" route. + */ +export const HOME_ORDERABLE_FIELDS: ProjectFieldName[] = [ + 'dateModified', + 'dateDeployed', + 'name', + 'status', + 'ownerUsername', +]; + +/** + * The inital fields that are going to be displayed. We also use them with + * "reset" fields button. + */ +export const DEFAULT_VISIBLE_FIELDS: ProjectFieldName[] = [ 'countries', 'dateModified', 'dateDeployed', @@ -328,3 +349,23 @@ export const DEFAULT_PROJECT_FIELDS: ProjectFieldName[] = [ 'status', 'submissions', ]; + +/** An override default list (instead of DEFAULT_VISIBLE_FIELDS) */ +export const HOME_DEFAULT_VISIBLE_FIELDS: ProjectFieldName[] = [ + 'dateModified', + 'dateDeployed', + 'name', + 'ownerUsername', + 'status', + 'submissions', +]; + +/** + * These are fields not available on the `/api/v2/assets/` endpoint - there is + * no point in displaying them to the user. + */ +export const HOME_EXCLUDED_FIELDS: ProjectFieldName[] = [ + 'ownerEmail', + 'ownerFullName', + 'ownerOrganization', +]; diff --git a/jsapp/js/projects/projectViews/projectsFieldsSelector.tsx b/jsapp/js/projects/projectViews/projectsFieldsSelector.tsx index a07314261f..70b2b2b076 100644 --- a/jsapp/js/projects/projectViews/projectsFieldsSelector.tsx +++ b/jsapp/js/projects/projectViews/projectsFieldsSelector.tsx @@ -7,7 +7,7 @@ import KoboModalHeader from 'js/components/modals/koboModalHeader'; import KoboModalContent from 'js/components/modals/koboModalContent'; import KoboModalFooter from 'js/components/modals/koboModalFooter'; import type {ProjectFieldName} from './constants'; -import {PROJECT_FIELDS, DEFAULT_PROJECT_FIELDS} from './constants'; +import {PROJECT_FIELDS, DEFAULT_VISIBLE_FIELDS} from './constants'; import styles from './projectsFieldsSelector.module.scss'; interface ProjectsFieldsSelectorProps { @@ -19,17 +19,23 @@ interface ProjectsFieldsSelectorProps { * again through props. */ onFieldsChange: (fields: ProjectFieldName[] | undefined) => void; + /** A list of fields that should not be available to user. */ + excludedFields?: ProjectFieldName[]; } export default function ProjectsFieldsSelector( props: ProjectsFieldsSelectorProps ) { const getInitialSelectedFields = () => { + let outcome: ProjectFieldName[] = []; if (!props.selectedFields || props.selectedFields.length === 0) { - return DEFAULT_PROJECT_FIELDS; + outcome = DEFAULT_VISIBLE_FIELDS; } else { - return Array.from(props.selectedFields); + outcome = Array.from(props.selectedFields); } + return outcome.filter( + (fieldName) => !props.excludedFields?.includes(fieldName) + ); }; const [isModalOpen, setIsModalOpen] = useState(false); @@ -38,12 +44,8 @@ export default function ProjectsFieldsSelector( ); useEffect(() => { - if (!isModalOpen) { - // Reset fields when closing modal. - if (isModalOpen === false) { - setSelectedFields(getInitialSelectedFields()); - } - } + // When opening and closing we reset fields + setSelectedFields(getInitialSelectedFields()); }, [isModalOpen]); const toggleModal = () => { @@ -69,15 +71,20 @@ export default function ProjectsFieldsSelector( }; const getCheckboxes = (): MultiCheckboxItem[] => - Object.values(PROJECT_FIELDS).map((field) => { - return { - name: field.name, - label: field.label, - // We ensure "name" field is always selected - checked: selectedFields.includes(field.name) || field.name === 'name', - disabled: field.name === 'name', - }; - }); + Object.values(PROJECT_FIELDS) + .filter( + (fieldDefinition) => + !props.excludedFields?.includes(fieldDefinition.name) + ) + .map((field) => { + return { + name: field.name, + label: field.label, + // We ensure "name" field is always selected + checked: selectedFields.includes(field.name) || field.name === 'name', + disabled: field.name === 'name', + }; + }); return (
    diff --git a/jsapp/js/projects/projectViews/projectsFilter.tsx b/jsapp/js/projects/projectViews/projectsFilter.tsx index 0d8cb732c7..795533cce9 100644 --- a/jsapp/js/projects/projectViews/projectsFilter.tsx +++ b/jsapp/js/projects/projectViews/projectsFilter.tsx @@ -4,7 +4,7 @@ import clonedeep from 'lodash.clonedeep'; import Button from 'js/components/common/button'; import KoboModal from 'js/components/modals/koboModal'; import KoboModalHeader from 'js/components/modals/koboModalHeader'; -import type {ProjectsFilterDefinition} from './constants'; +import type {ProjectFieldName, ProjectsFilterDefinition} from './constants'; import ProjectsFilterEditor from './projectsFilterEditor'; import {removeIncorrectFilters} from './utils'; import styles from './projectsFilter.module.scss'; @@ -21,6 +21,8 @@ interface ProjectsFilterProps { * new filters. */ onFiltersChange: (filters: ProjectsFilterDefinition[]) => void; + /** A list of fields that should not be available to user. */ + excludedFields?: ProjectFieldName[]; } export default function ProjectsFilter(props: ProjectsFilterProps) { @@ -105,11 +107,7 @@ export default function ProjectsFilter(props: ProjectsFilterProps) { /> )} - + { onFilterEditorDelete(filterIndex); }} + excludedFields={props.excludedFields} /> ))} diff --git a/jsapp/js/projects/projectViews/projectsFilterEditor.tsx b/jsapp/js/projects/projectViews/projectsFilterEditor.tsx index c233e38f89..01f44b7eee 100644 --- a/jsapp/js/projects/projectViews/projectsFilterEditor.tsx +++ b/jsapp/js/projects/projectViews/projectsFilterEditor.tsx @@ -20,6 +20,8 @@ interface ProjectsFilterEditorProps { /** Called on every change. */ onFilterChange: (filter: ProjectsFilterDefinition) => void; onDelete: () => void; + /** A list of fields that should not be available to user. */ + excludedFields?: ProjectFieldName[]; } const COUNTRIES = envStore.data.country_choices; @@ -67,6 +69,11 @@ export default function ProjectsFilterEditor(props: ProjectsFilterEditorProps) { .filter( (filterDefinition) => filterDefinition.availableConditions.length >= 1 ) + // We don't want to display excluded fields. + .filter( + (filterDefinition) => + !props.excludedFields?.includes(filterDefinition.name) + ) .map((filterDefinition) => { return {label: filterDefinition.label, value: filterDefinition.name}; }); @@ -79,12 +86,16 @@ export default function ProjectsFilterEditor(props: ProjectsFilterEditorProps) { return fieldDefinition.availableConditions.map( (condition: FilterConditionName) => { const conditionDefinition = FILTER_CONDITIONS[condition]; - return {label: conditionDefinition.label, value: conditionDefinition.name}; + return { + label: conditionDefinition.label, + value: conditionDefinition.name, + }; } ); }; - const isCountryFilterSelected = props.filter.fieldName && props.filter.fieldName === 'countries'; + const isCountryFilterSelected = + props.filter.fieldName && props.filter.fieldName === 'countries'; return (
    diff --git a/jsapp/js/projects/projectViews/viewSwitcher.module.scss b/jsapp/js/projects/projectViews/viewSwitcher.module.scss index a72daa39df..694d81753f 100644 --- a/jsapp/js/projects/projectViews/viewSwitcher.module.scss +++ b/jsapp/js/projects/projectViews/viewSwitcher.module.scss @@ -2,7 +2,6 @@ @use 'scss/sizes'; @use '~kobo-common/src/styles/colors'; @use 'scss/mixins'; -@use 'js/components/common/button'; .root { display: inline-block; @@ -24,8 +23,14 @@ font-weight: 800; color: colors.$kobo-gray-24; padding: sizes.$x6 sizes.$x16; + max-width: sizes.$x400; :global { + label { + @include mixins.textEllipsis; + pointer-events: none; + } + .k-icon { margin-left: sizes.$x5; } @@ -36,7 +41,12 @@ } } +.triggerSimple { + cursor: default; +} + .menu { + @include mixins.floatingRoundedBox; display: block; width: 100%; min-width: sizes.$x300; @@ -44,9 +54,6 @@ max-width: sizes.$x300; overflow-x: auto; padding: sizes.$x20 0; - border-radius: button.$button-border-radius; - background-color: colors.$kobo-white; - box-shadow: 0 0 sizes.$x6 color.change(colors.$kobo-storm, $alpha: 0.3); } .menuOption { diff --git a/jsapp/js/projects/projectViews/viewSwitcher.tsx b/jsapp/js/projects/projectViews/viewSwitcher.tsx index 855835da6f..a3a68b1464 100644 --- a/jsapp/js/projects/projectViews/viewSwitcher.tsx +++ b/jsapp/js/projects/projectViews/viewSwitcher.tsx @@ -3,11 +3,8 @@ import {useNavigate} from 'react-router-dom'; import {observer} from 'mobx-react-lite'; import classNames from 'classnames'; import Icon from 'js/components/common/icon'; -import KoboDropdown, { - KoboDropdownPlacements, -} from 'js/components/common/koboDropdown'; +import KoboDropdown from 'js/components/common/koboDropdown'; import {PROJECTS_ROUTES} from 'js/projects/routes'; -import {ROUTES} from 'js/router/routerConstants'; import projectViewsStore from './projectViewsStore'; import styles from './viewSwitcher.module.scss'; import {HOME_VIEW} from './constants'; @@ -24,8 +21,7 @@ function ViewSwitcher(props: ViewSwitcherProps) { const onOptionClick = (viewUid: string) => { if (viewUid === HOME_VIEW.uid || viewUid === null) { - // TODO change this to PROJECTS_ROUTES.MY_PROJECTS - navigate(ROUTES.FORMS); + navigate(PROJECTS_ROUTES.MY_PROJECTS); } else { navigate(PROJECTS_ROUTES.CUSTOM_VIEW.replace(':viewUid', viewUid)); // The store keeps a number of assets of each view, and that number @@ -34,20 +30,29 @@ function ViewSwitcher(props: ViewSwitcherProps) { } }; - const getTriggerLabel = () => { - if (props.selectedViewUid === HOME_VIEW.uid) { - return HOME_VIEW.name; - } - - return projectViews.getView(props.selectedViewUid)?.name; - }; + let triggerLabel = HOME_VIEW.name; + if (props.selectedViewUid !== HOME_VIEW.uid) { + triggerLabel = projectViews.getView(props.selectedViewUid)?.name || '-'; + } - // We don't want to display anything before the API call is done. If there are - // no custom views defined, there's no point in displaying it either. - if (!projectViews.isFirstLoadComplete || projectViews.views.length === 0) { + // We don't want to display anything before the API call is done. + if (!projectViews.isFirstLoadComplete) { return null; } + // If there are no custom views defined, there's no point in displaying + // the dropdown, we will display a "simple" header. + if (projectViews.views.length === 0) { + return ( + + ); + } + return (
    - {getTriggerLabel()} + } diff --git a/jsapp/js/projects/projectsTable/bulkActions/bulkDeletePrompt.module.scss b/jsapp/js/projects/projectsTable/bulkActions/bulkDeletePrompt.module.scss new file mode 100644 index 0000000000..31983f848f --- /dev/null +++ b/jsapp/js/projects/projectsTable/bulkActions/bulkDeletePrompt.module.scss @@ -0,0 +1,11 @@ +@use 'scss/sizes'; + +.promptContent { + display: flex; + flex-direction: column; + gap: sizes.$x20; + + :global p { + margin: 0; + } +} diff --git a/jsapp/js/projects/projectsTable/bulkActions/bulkDeletePrompt.tsx b/jsapp/js/projects/projectsTable/bulkActions/bulkDeletePrompt.tsx new file mode 100644 index 0000000000..436edf7592 --- /dev/null +++ b/jsapp/js/projects/projectsTable/bulkActions/bulkDeletePrompt.tsx @@ -0,0 +1,110 @@ +import React, {useState} from 'react'; +import {fetchPost} from 'js/api'; +import {handleApiFail, notify} from 'js/utils'; +import KoboPrompt from 'js/components/modals/koboPrompt'; +import Checkbox from 'js/components/common/checkbox'; +import styles from './bulkDeletePrompt.module.scss'; +import customViewStore from 'js/projects/customViewStore'; +import {searches} from 'js/searches'; + +type AssetsBulkAction = 'archive' | 'delete' | 'unarchive'; +interface AssetsBulkResponse { + detail: string; +} + +interface BulkDeletePromptProps { + assetUids: string[]; + /** Being used by the parent component to close the prompt. */ + onRequestClose: () => void; +} + +export default function BulkDeletePrompt(props: BulkDeletePromptProps) { + const [isDataChecked, setIsDataChecked] = useState(false); + const [isFormChecked, setIsFormChecked] = useState(false); + const [isRecoverChecked, setIsRecoverChecked] = useState(false); + const [isConfirmDeletePending, setIsConfirmDeletePending] = useState(false); + + function onConfirmDelete() { + setIsConfirmDeletePending(true); + + const payload: {asset_uids: string[]; action: AssetsBulkAction} = { + asset_uids: props.assetUids, + action: 'delete', + }; + + fetchPost('/api/v2/assets/bulk/', {payload: payload}) + .then((response) => { + props.onRequestClose(); + customViewStore.handleAssetsDeleted(props.assetUids); + + // Temporarily we do this hacky thing to update the sidebar list of + // projects. After the Bookmarked Projects feature is done (see the + // https://github.com/kobotoolbox/kpi/issues/4220 for history of + // discussion and more details) we would remove this code. + searches.forceRefreshFormsList(); + + notify(response.detail); + }) + .catch(handleApiFail); + } + + return ( + +
    +

    + {t('You are about to permanently delete ##count## projects').replace( + '##count##', + String(props.assetUids.length) + )} +

    + + + + + + + + +
    +
    + ); +} diff --git a/jsapp/js/projects/projectsTable/columnResizer.tsx b/jsapp/js/projects/projectsTable/columnResizer.tsx index a01a0f407a..6451f655d5 100644 --- a/jsapp/js/projects/projectsTable/columnResizer.tsx +++ b/jsapp/js/projects/projectsTable/columnResizer.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect, useRef} from 'react'; +import React, {memo, useState, useEffect, useRef} from 'react'; import type {ProjectFieldName} from '../projectViews/constants'; /** @@ -116,7 +116,7 @@ function ColumnWidthsStyle(props: {columnWidths: ColumnWidths}) { * * …even if the pointer strays from the resize handle. */ -function DraggingStyle(props: { +const DraggingStyle = memo(function DraggingStyle(props: { isDragging: boolean; draggingFieldname: string; }) { @@ -134,7 +134,7 @@ function DraggingStyle(props: { }`} ); -} +}); /** * * diff --git a/jsapp/js/projects/projectsTable/projectActions.module.scss b/jsapp/js/projects/projectsTable/projectActions.module.scss new file mode 100644 index 0000000000..8c65c8f8cf --- /dev/null +++ b/jsapp/js/projects/projectsTable/projectActions.module.scss @@ -0,0 +1,20 @@ +@use '~kobo-common/src/styles/colors'; +@use 'scss/sizes'; +@use 'scss/mixins'; + +.root { + @include mixins.centerRowFlex; + gap: sizes.$x10; +} + +.menu { + @include mixins.floatingRoundedBox; + padding: sizes.$x6; + min-width: sizes.$x180; + + // There is a `isFullWidth` property on Button component, but it also has text + // centering styles on it, so we can't use it. + :global .k-button { + width: 100%; + } +} diff --git a/jsapp/js/projects/projectsTable/projectBulkActions.tsx b/jsapp/js/projects/projectsTable/projectBulkActions.tsx new file mode 100644 index 0000000000..d202f070fe --- /dev/null +++ b/jsapp/js/projects/projectsTable/projectBulkActions.tsx @@ -0,0 +1,38 @@ +import React, {useState} from 'react'; +import type {AssetResponse, ProjectViewAsset} from 'js/dataInterface'; +import Button from 'js/components/common/button'; +import actionsStyles from './projectActions.module.scss'; +import BulkDeletePrompt from './bulkActions/bulkDeletePrompt'; + +interface ProjectBulkActionsProps { + /** A list of selected assets for bulk operations. */ + assets: Array; +} + +export default function ProjectBulkActions(props: ProjectBulkActionsProps) { + const [isDeletePromptOpen, setIsDeletePromptOpen] = useState(false); + + return ( +
    +
    + ); +} diff --git a/jsapp/js/projects/projectsTable/projectQuickActions.tsx b/jsapp/js/projects/projectsTable/projectQuickActions.tsx new file mode 100644 index 0000000000..4f85ab04c5 --- /dev/null +++ b/jsapp/js/projects/projectsTable/projectQuickActions.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import type { + AssetResponse, + ProjectViewAsset, + DeploymentResponse, +} from 'js/dataInterface'; +import {ASSET_TYPES} from 'js/constants'; +import Button from 'js/components/common/button'; +import KoboDropdown from 'jsapp/js/components/common/koboDropdown'; +import styles from './projectActions.module.scss'; +import {getAssetDisplayName} from 'jsapp/js/assetUtils'; +import { + archiveAsset, + unarchiveAsset, + deleteAsset, + openInFormBuilder, + manageAssetSharing, + cloneAsset, + deployAsset, + replaceAssetForm, + manageAssetLanguages, + cloneAssetAsTemplate, + cloneAssetAsSurvey, +} from 'jsapp/js/assetQuickActions'; +import {downloadUrl} from 'jsapp/js/utils'; +import type {IconName} from 'jsapp/fonts/k-icons'; +import {userCan} from 'js/components/permissions/utils'; +import customViewStore from 'js/projects/customViewStore'; + +interface ProjectQuickActionsProps { + asset: AssetResponse | ProjectViewAsset; +} + +export default function ProjectQuickActions(props: ProjectQuickActionsProps) { + // The `userCan` method requires `permissions` property to be present in the + // `asset` object. For performance reasons `ProjectViewAsset` doesn't have + // that property, and it is fine, as we don't expect Project View to have + // a lot of options available. + const isChangingPossible = userCan('change_asset', props.asset); + const isManagingPossible = userCan('manage_asset', props.asset); + + return ( +
    +
    + } + /> +
    + ); +} diff --git a/jsapp/js/projects/projectsTable/projectsTable.module.scss b/jsapp/js/projects/projectsTable/projectsTable.module.scss index 13b3ccb34b..e3fba0a52a 100644 --- a/jsapp/js/projects/projectsTable/projectsTable.module.scss +++ b/jsapp/js/projects/projectsTable/projectsTable.module.scss @@ -1,7 +1,5 @@ @use 'scss/sizes'; @use '~kobo-common/src/styles/colors'; -@use 'scss/variables'; -@use 'scss/libs/mdl'; @use 'scss/z-indexes'; $projects-table-min-width: 820px; @@ -13,18 +11,6 @@ $projects-table-min-width: 820px; flex-direction: column; background-color: colors.$kobo-white; overflow: auto; - - &.fullscreen { - position: fixed; - z-index: z-indexes.$z-fullscreen; - border: sizes.$x5 solid mdl.$root-background; - top: 0; - left: 0; - bottom: 0; - right: 0; - width: 100%; - height: 100%; - } } .header { diff --git a/jsapp/js/projects/projectsTable/projectsTable.tsx b/jsapp/js/projects/projectsTable/projectsTable.tsx index 3700cd26dc..6aeed14dd4 100644 --- a/jsapp/js/projects/projectsTable/projectsTable.tsx +++ b/jsapp/js/projects/projectsTable/projectsTable.tsx @@ -15,8 +15,8 @@ import classNames from 'classnames'; const SCROLL_PARENT_ID = 'projects-table-is-using-infinite_scroll-successfully'; export interface ProjectsTableOrder { - fieldName: ProjectFieldName; - direction: OrderDirection; + fieldName?: ProjectFieldName; + direction?: OrderDirection; } interface ProjectsTableProps { @@ -27,6 +27,8 @@ interface ProjectsTableProps { /** Renders the columns for highlighted fields in some fancy way. */ highlightedFields: ProjectFieldName[]; visibleFields: ProjectFieldName[]; + /** The fields that have ability to change the order of data. */ + orderableFields: ProjectFieldName[]; order: ProjectsTableOrder; /** Called when user selects a column for odering. */ onChangeOrderRequested: (order: ProjectsTableOrder) => void; @@ -35,6 +37,10 @@ interface ProjectsTableProps { onRequestLoadNextPage: () => void; /** If there are more results to be loaded. */ hasMorePages: boolean; + /** A list of uids */ + selectedRows: string[]; + /** Called when user selects a row (by clicking its checkbox) */ + onRowsSelected: (uids: string[]) => void; } /** @@ -46,6 +52,16 @@ export default function ProjectsTable(props: ProjectsTableProps) { new Set(props.visibleFields).add('name') ); + const onRowSelectionChange = (rowUid: string, isSelected: boolean) => { + const uidsSet = new Set(props.selectedRows); + if (isSelected) { + uidsSet.add(rowUid); + } else { + uidsSet.delete(rowUid); + } + props.onRowsSelected(Array.from(uidsSet)); + }; + return ( // NOTE: react-infinite-scroller wants us to use refs, but there seems to // be some kind of a bug - either in their code or their typings. Thus we @@ -54,6 +70,7 @@ export default function ProjectsTable(props: ProjectsTableProps) { + onRowSelectionChange(asset.uid, isSelected) + } key={asset.uid} /> ))} diff --git a/jsapp/js/projects/projectsTable/projectsTableHeader.module.scss b/jsapp/js/projects/projectsTable/projectsTableHeader.module.scss index e94af7e219..c5e2c1e0ae 100644 --- a/jsapp/js/projects/projectsTable/projectsTableHeader.module.scss +++ b/jsapp/js/projects/projectsTable/projectsTableHeader.module.scss @@ -3,6 +3,8 @@ @use 'scss/mixins'; @use 'scss/sizes'; @use '~kobo-common/src/styles/colors'; +@use 'js/components/common/icon'; +@use './projectsTableRow.module'; // This file contains all the styles that are being used exclusively in // `ProjectsTableHeader` component. Most of styles it is using are coming @@ -11,33 +13,53 @@ // Column Resizer handle styles are here, too. .columnRoot { - // We need this to truncate long column names - :global .kobo-dropdown, - :global .kobo-dropdown__trigger { - max-width: 100%; - } - // For column width resizers position: relative; // Be a positioned ancestor for resize handles user-select: none; // Prevent accidental text selection + + // Make the whole header cell clickable for the dropdown trigger. + :global .kobo-dropdown, + :global .kobo-dropdown__trigger { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } } .trigger { @include mixins.centerRowFlex; + padding-top: 0; + padding-bottom: 0; + padding-right: projectsTableRow.$projects-table-row-padding; + // 10 px is a magic number that puts the caret icon in the same line as the + // content of the cells below + padding-left: projectsTableRow.$projects-table-row-padding + sizes.$x10; + cursor: pointer; - line-height: sizes.$x30; // ?? + line-height: projectsTableRow.$projects-table-row-height; :global { + // Dropdown arrow + .k-icon:first-child { + // Pull the dropdown label and arrow to the left somewhat. + // This measurement isn't scientific, it's just supposed to look good. + margin-left: 0 - icon.$s-icon-xxs - sizes.$x2; + padding-right: sizes.$x3; + opacity: 0.6; + } + + // Dropdown Label label { @include mixins.textEllipsis; - - cursor: inherit; - flex: 1; } + // Sorting indicator .k-icon:not(:first-child) { margin-left: sizes.$x6; + color: colors.$kobo-teal; // make it stand out } } } @@ -47,9 +69,7 @@ } .dropdownContent { - background-color: colors.$kobo-white; - border-radius: sizes.$x6; - box-shadow: 0 0 sizes.$x6 color.change(colors.$kobo-storm, $alpha: 0.3); + @include mixins.floatingRoundedBox; padding: sizes.$x10; min-width: sizes.$x120; @@ -154,6 +174,7 @@ $resizer-width: sizes.$x16 + $line-width; opacity: 1; } // Always hide leftmost width indicator -.columnRoot:first-child::before { +// nth-child(2) because of checkbox column +.columnRoot:nth-child(2)::before { display: none; } diff --git a/jsapp/js/projects/projectsTable/projectsTableHeader.tsx b/jsapp/js/projects/projectsTable/projectsTableHeader.tsx index 9dc21b6a07..cb5f6343a7 100644 --- a/jsapp/js/projects/projectsTable/projectsTableHeader.tsx +++ b/jsapp/js/projects/projectsTable/projectsTableHeader.tsx @@ -10,15 +10,14 @@ import rowStyles from './projectsTableRow.module.scss'; import styles from './projectsTableHeader.module.scss'; import classNames from 'classnames'; import Icon from 'js/components/common/icon'; -import KoboDropdown, { - KoboDropdownPlacements, -} from 'js/components/common/koboDropdown'; +import KoboDropdown from 'js/components/common/koboDropdown'; import Button from 'jsapp/js/components/common/button'; import ColumnResizer from './columnResizer'; interface ProjectsTableHeaderProps { highlightedFields: ProjectFieldName[]; visibleFields: ProjectFieldName[]; + orderableFields: ProjectFieldName[]; order: ProjectsTableOrder; onChangeOrderRequested: (order: ProjectsTableOrder) => void; onHideFieldRequested: (fieldName: ProjectFieldName) => void; @@ -53,7 +52,7 @@ export default function ProjectsTableHeader(props: ProjectsTableHeaderProps) { > { let newVisibleMenuNames = Array.from(visibleMenuNames); @@ -68,6 +67,11 @@ export default function ProjectsTableHeader(props: ProjectsTableHeaderProps) { }} triggerContent={
    + + {props.order.fieldName === field.name && ( @@ -80,16 +84,23 @@ export default function ProjectsTableHeader(props: ProjectsTableHeaderProps) { size='s' /> )} - -
    } menuContent={
    - {field.orderable && ( + {props.orderableFields.includes(field.name) && ( +