diff --git a/package-lock.json b/package-lock.json index 767178048..4dfb8d21b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "slate-hyperscript": "^0.100.0", "slate-react": "^0.111.0", "source-map-explorer": "^2.5.3", - "terraso-client-shared": "github:techmatters/terraso-client-shared#8cd860e", + "terraso-client-shared": "github:techmatters/terraso-client-shared#bec5d03dcaf0c47bc2e48661d35a4b73ed2b70fd", "use-debounce": "^10.0.4", "uuid": "^11.0.3", "web-vitals": "^4.2.4", @@ -30257,19 +30257,20 @@ }, "node_modules/terraso-backend": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#b61c60caf963a400bffa8016cb6f894d5d9d72b4", - "integrity": "sha512-rM6ZXO+dX5GT3QuERDJ5PW03O1buJdD7DOkFiMnKbvQobAT+L71NypMKsZrcfxi4+6gT2dYo1j/5EmkFQxVoJQ==" + "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#29d558f773853df94973499ab7c9f8c97fc74659", + "integrity": "sha512-Hvl31X7cLIl8V0EFhVRzyxRLEVv6MYFEIW0oEGDLc1p8p9PzVB9I889nl4pUCna4rJz16PZH6ywFj1Y3hnRLGg==" }, "node_modules/terraso-client-shared": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/techmatters/terraso-client-shared.git#8cd860ef31575487970a59a14cf6a700e1c156bf", + "resolved": "git+ssh://git@github.com/techmatters/terraso-client-shared.git#bec5d03dcaf0c47bc2e48661d35a4b73ed2b70fd", + "integrity": "sha512-CwEmZFMlLY0vq2wb1RR8EseEPj53gBGpLxHsHPlCfKUSogDKYCypdWXa9Sggn04C/Uufp5JjV6rUicuKRWglcQ==", "dependencies": { "@reduxjs/toolkit": "^1.9.7", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "react": "^18.3.1", "react-redux": "^8.1.3", - "terraso-backend": "github:techmatters/terraso-backend#0a4e249", + "terraso-backend": "github:techmatters/terraso-backend#29d558f773853df94973499ab7c9f8c97fc74659", "uuid": "^10.0.0" }, "engines": { diff --git a/package.json b/package.json index e77874193..4d7e21b12 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "slate-hyperscript": "^0.100.0", "slate-react": "^0.111.0", "source-map-explorer": "^2.5.3", - "terraso-client-shared": "github:techmatters/terraso-client-shared#8cd860e", + "terraso-client-shared": "github:techmatters/terraso-client-shared#bec5d03dcaf0c47bc2e48661d35a4b73ed2b70fd", "use-debounce": "^10.0.4", "uuid": "^11.0.3", "web-vitals": "^4.2.4", diff --git a/src/gis/components/Map.js b/src/gis/components/Map.js index 0196a18b6..eb01767a2 100644 --- a/src/gis/components/Map.js +++ b/src/gis/components/Map.js @@ -305,7 +305,7 @@ const Map = React.forwardRef((props, ref) => { id, mapStyle, projection, - initialLocation, + initialLocation: propsInitialLocation, interactive = true, hash = false, attributionControl = true, @@ -325,6 +325,7 @@ const Map = React.forwardRef((props, ref) => { const { map, setMap } = useMap(); const mapContainer = useRef(null); const [bounds] = useState(initialBounds); + const [initialLocation] = useState(propsInitialLocation); useEffect(() => { const validBounds = isValidBounds(bounds); diff --git a/src/localization/locales/en-US.json b/src/localization/locales/en-US.json index 2979f25eb..2b97ea3ad 100644 --- a/src/localization/locales/en-US.json +++ b/src/localization/locales/en-US.json @@ -887,11 +887,13 @@ "form_chapter_no_title_label": "Untitled", "form_back_button": "Story Maps", "form_byline": "By $t(user.full_name)", - "form_share_button": "Invite", - "form_preview_button": "Preview", + "form_share_button": "Invite editors", + "form_actions_button": "Actions", + "form_preview_button": "Preview draft", + "form_view_published_button": "View published Story Map", "form_save_draft_button": "Save draft", "form_publish_button": "Publish", - "form_republish_button": "Save & Republish", + "form_republish_button": "Publish Changes", "form_preview_close": "Exit Preview", "form_preview_title": "You are previewing <1>{{title}}", "form_preview_title_blank": "You are previewing", @@ -977,6 +979,9 @@ "form_no_title_label": "Untitled", "form_title_location_button": "Set Map Location", "form_title_location_dialog_title": "Title", + "form_save_status_saving": "Saving...", + "form_save_status_saved": "Draft saved", + "form_save_status_error": "Save failed, trying to connect", "update_story_map": "{{title}} story map has been updated.", "update_story_map_published": "{{title}} story map has been published.", "added_story_map": "{{title}} story map has been saved.", diff --git a/src/storyMap/components/StoryMap.js b/src/storyMap/components/StoryMap.js index bc5224375..ddb21d0c6 100644 --- a/src/storyMap/components/StoryMap.js +++ b/src/storyMap/components/StoryMap.js @@ -477,7 +477,9 @@ const Scroller = props => { filtered.forEach(setLayerOpacity); } }); - + // This is needed for development due to resize observer issue + // should not be here on production + // scroller.disable(); setIsReady(true); window.addEventListener('resize', scroller.resize); diff --git a/src/storyMap/components/StoryMapForm/TopBar.js b/src/storyMap/components/StoryMapForm/TopBar.js index 02ffce873..7e4c51e73 100644 --- a/src/storyMap/components/StoryMapForm/TopBar.js +++ b/src/storyMap/components/StoryMapForm/TopBar.js @@ -15,24 +15,84 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { Button, Divider, Grid, Typography } from '@mui/material'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import CheckIcon from '@mui/icons-material/Check'; +import ErrorIcon from '@mui/icons-material/Error'; +import SyncIcon from '@mui/icons-material/Sync'; +import { + Button, + Divider, + Grid, + Link, + Menu, + MenuItem, + Stack, + Typography, +} from '@mui/material'; import RouterLink from 'common/components/RouterLink'; +import { generateStoryMapUrl } from 'storyMap/storyMapUtils'; import ShareDialog from './ShareDialog'; import { useStoryMapConfigContext } from './storyMapConfigContext'; import TopBarContainer from './TopBarContainer'; -const TopBar = props => { +const SAVE_STATUS = { + saving: { + message: 'storyMap.form_save_status_saving', + Icon: SyncIcon, + }, + saved: { + message: 'storyMap.form_save_status_saved', + Icon: CheckIcon, + }, + error: { + message: 'storyMap.form_save_status_error', + Icon: ErrorIcon, + color: 'error.main', + }, +}; + +const SaveStatus = props => { const { t } = useTranslation(); - const { storyMap, config, setPreview } = useStoryMapConfigContext(); - const { onPublish, onSaveDraft } = props; - const [openShareDialog, setOpenShareDialog] = React.useState(false); + const { requestStatus, isDirty } = props; + const { error } = requestStatus; - const isPublished = storyMap?.isPublished; + const status = error ? 'error' : isDirty ? 'saving' : 'saved'; + const Icon = SAVE_STATUS[status].Icon; + const message = SAVE_STATUS[status].message; + const color = SAVE_STATUS[status].color; + + return ( + + + {t(message)} + + ); +}; + +const ActionsMenu = () => { + const { t } = useTranslation(); + const { storyMap, setPreview } = useStoryMapConfigContext(); + const [anchorEl, setAnchorEl] = useState(null); + const [openShareDialog, setOpenShareDialog] = useState(false); + + const open = Boolean(anchorEl); + + const handleClick = event => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; return ( <> @@ -42,77 +102,101 @@ const TopBar = props => { onClose={() => setOpenShareDialog(false)} /> )} - - - } + > + {t('storyMap.form_actions_button')} + + + setPreview(true)}> + {t('storyMap.form_preview_button')} + + + {storyMap && ( + - - - {t('storyMap.form_back_button')} - - - - - - {config.title || t('storyMap.form_no_title_label')} + {t('storyMap.form_view_published_button')} + + )} + {storyMap && } + {storyMap && ( + setOpenShareDialog(true)}> + {t('storyMap.form_share_button')} + + )} + + + ); +}; + +const TopBar = props => { + const { t } = useTranslation(); + const { storyMap, config } = useStoryMapConfigContext(); + const { onPublish, isDirty, requestStatus } = props; + + const isPublished = storyMap?.isPublished; + + return ( + + + + + + {t('storyMap.form_back_button')} - - + + + + {config.title || t('storyMap.form_no_title_label')} + + + + - {storyMap && ( - <> - - - - )} - - {!isPublished && ( - - )} - + + - - - + + + ); }; diff --git a/src/storyMap/components/StoryMapForm/index.js b/src/storyMap/components/StoryMapForm/index.js index 7f4982964..5fbeba7bc 100644 --- a/src/storyMap/components/StoryMapForm/index.js +++ b/src/storyMap/components/StoryMapForm/index.js @@ -19,10 +19,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import _ from 'lodash/fp'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; +import logger from 'terraso-client-shared/monitoring/logger'; +import { useDebounce } from 'use-debounce'; import { v4 as uuidv4 } from 'uuid'; import { Grid, useMediaQuery } from '@mui/material'; -import PageLoader from 'layout/PageLoader'; import { useAnalytics } from 'monitoring/analytics'; import NavigationBlockedDialog from 'navigation/components/NavigationBlockedDialog'; import { useNavigationBlocker } from 'navigation/navigationContext'; @@ -38,6 +39,8 @@ import TopBarPreview from './TopBarPreview'; import theme from 'theme'; +const AUTO_SAVE_DEBOUNCE = 3000; + const BASE_CHAPTER = { alignment: 'left', title: '', @@ -90,7 +93,8 @@ const StoryMapForm = props => { const { trackEvent } = useAnalytics(); const isSmall = useMediaQuery(theme.breakpoints.down('md')); const { onPublish, onSaveDraft } = props; - const { saving } = useSelector(_.get('storyMap.form')); + const requestStatus = useSelector(_.get('storyMap.form')); + const { error: saveError, saving } = requestStatus; const { storyMap, config, @@ -106,6 +110,34 @@ const StoryMapForm = props => { const [currentStepId, setCurrentStepId] = useState(); const [scrollToChapter, setScrollToChapter] = useState(); + const [autoSaveData, setAutoSaveData] = useState({ + config, + mediaFiles, + isDirty, + }); + const [autoSaveDataDebounced] = useDebounce(autoSaveData, AUTO_SAVE_DEBOUNCE); + useEffect(() => { + setAutoSaveData({ + config, + mediaFiles, + isDirty, + saving, + saveError, + }); + }, [config, mediaFiles, isDirty, saving, saveError]); + + useEffect(() => { + const { config, mediaFiles, isDirty } = autoSaveDataDebounced; + if (!isDirty) { + return; + } + onSaveDraft(config, mediaFiles) + .then(saved) + .catch(error => { + logger.error('Error auto saving story map', error); + }); + }, [autoSaveDataDebounced, onSaveDraft, saved]); + const isFirefox = useMemo( () => navigator.userAgent.toLowerCase().indexOf('firefox') > -1, [] @@ -223,7 +255,7 @@ const StoryMapForm = props => { }, [config, mediaFiles, onPublish, saved]); const onSaveDraftWrapper = useCallback(() => { - onSaveDraft(config, mediaFiles).then(saved); + return onSaveDraft(config, mediaFiles).then(saved); }, [config, mediaFiles, onSaveDraft, saved]); if (preview || isSmall) { @@ -240,8 +272,12 @@ const StoryMapForm = props => { onCancel={cancel} /> )} - {saving && } - + { return id; }, []); + const clearMediaFiles = useCallback(() => { + setMediaFiles({}); + }, []); + const getMediaFile = useCallback(id => mediaFiles[id]?.content, [mediaFiles]); const saved = useCallback(() => setIsDirty(false), []); const setConfigWrapper = useCallback( - newConfigSetter => { + (newConfigSetter, dirty = true) => { setConfig(currentConfig => { const newConfig = typeof newConfigSetter === 'function' @@ -66,7 +70,7 @@ export const StoryMapConfigContextProvider = props => { dataLayers: _.pick(usedDataLayersIds, newConfig.dataLayers), }; }); - setIsDirty(true); + setIsDirty(dirty); }, [setConfig] ); @@ -81,6 +85,7 @@ export const StoryMapConfigContextProvider = props => { mediaFiles, addMediaFile, getMediaFile, + clearMediaFiles, init, saved, isDirty, @@ -92,6 +97,7 @@ export const StoryMapConfigContextProvider = props => { mediaFiles, addMediaFile, getMediaFile, + clearMediaFiles, init, setConfigWrapper, isDirty, diff --git a/src/storyMap/components/StoryMapNew.js b/src/storyMap/components/StoryMapNew.js index 319193189..87e966ddb 100644 --- a/src/storyMap/components/StoryMapNew.js +++ b/src/storyMap/components/StoryMapNew.js @@ -133,12 +133,12 @@ const StoryMapNew = () => { }, [dispatch, navigate, trackEvent, saved]); const save = useCallback( - (config, mediaFiles, published) => + (config, mediaFiles, publish) => dispatch( addStoryMap({ storyMap: { config, - published, + publish, }, files: mediaFiles, }) @@ -149,7 +149,7 @@ const StoryMapNew = () => { const storyMapId = _.get('payload.story_map_id', data); const id = _.get('payload.id', data); - setSaved({ id, slug, storyMapId, published }); + setSaved({ id, slug, storyMapId, published: publish }); return; } return Promise.reject(data); diff --git a/src/storyMap/components/StoryMapUpdate.js b/src/storyMap/components/StoryMapUpdate.js index 881ce8d06..c1f661b7e 100644 --- a/src/storyMap/components/StoryMapUpdate.js +++ b/src/storyMap/components/StoryMapUpdate.js @@ -39,15 +39,18 @@ import { } from 'storyMap/storyMapUtils'; import StoryMapForm from './StoryMapForm'; -import { StoryMapConfigContextProvider } from './StoryMapForm/storyMapConfigContext'; +import { + StoryMapConfigContextProvider, + useStoryMapConfigContext, +} from './StoryMapForm/storyMapConfigContext'; const StoryMapUpdate = props => { const navigate = useNavigate(); const dispatch = useDispatch(); const { t } = useTranslation(); const { trackEvent } = useAnalytics(); - const { storyMap } = props; const [saved, setSaved] = useState(); + const { storyMap, setConfig, clearMediaFiles } = useStoryMapConfigContext(); useDocumentTitle( t('storyMap.edit_document_title', { @@ -83,18 +86,24 @@ const StoryMapUpdate = props => { } if (title !== storyMap?.title) { - navigate(generateStoryMapEditUrl({ slug, storyMapId })); + window.history.pushState( + null, + t('storyMap.edit_document_title', { + name: _.get('title', storyMap), + }), + generateStoryMapEditUrl({ slug, storyMapId }) + ); } - }, [storyMap, navigate, trackEvent, saved]); + }, [storyMap, navigate, trackEvent, saved, t, dispatch]); const save = useCallback( - (config, mediaFiles, published) => + (config, mediaFiles, publish) => dispatch( updateStoryMap({ storyMap: { id: storyMap?.id, config, - published, + publish, }, files: mediaFiles, }) @@ -105,19 +114,22 @@ const StoryMapUpdate = props => { const storyMapId = _.get('payload.story_map_id', data); const title = _.get('payload.title', data); const id = _.get('payload.id', data); + const config = _.get('payload.configuration', data); setSaved({ id, title, slug, storyMapId, - published, + published: publish, }); + clearMediaFiles(); + setConfig(config, false); return; } return Promise.reject(data); }), - [storyMap?.id, dispatch] + [storyMap?.id, dispatch, clearMediaFiles, setConfig] ); const onPublish = useCallback( (config, mediaFiles) => save(config, mediaFiles, true), @@ -164,7 +176,7 @@ const ContextWrapper = props => { baseConfig={storyMap.config} storyMap={storyMap} > - + ); }; diff --git a/src/storyMap/storyMapFragments.js b/src/storyMap/storyMapFragments.js index eadee1d20..af6f6f92e 100644 --- a/src/storyMap/storyMapFragments.js +++ b/src/storyMap/storyMapFragments.js @@ -35,6 +35,13 @@ export const storyMapFields = /* GraphQL */ ` } `; +export const storyMapPublishedFields = /* GraphQL */ ` + fragment storyMapPublishedFields on StoryMapNode { + ...storyMapFields + publishedConfiguration + } +`; + export const storyMapMetadataFields = /* GraphQL */ ` fragment storyMapMetadataFields on StoryMapNode { id diff --git a/src/storyMap/storyMapService.js b/src/storyMap/storyMapService.js index 65273fcc8..ccdaf60d3 100644 --- a/src/storyMap/storyMapService.js +++ b/src/storyMap/storyMapService.js @@ -67,6 +67,36 @@ export const fetchSamples = (params, currentUser) => { export const fetchStoryMap = ({ slug, storyMapId }) => { const query = graphql(` query fetchStoryMap($slug: String!, $storyMapId: String!) { + storyMaps(slug: $slug, storyMapId: $storyMapId) { + edges { + node { + ...storyMapPublishedFields + membershipList { + ...collaborationMemberships + ...accountCollaborationMembership + } + } + } + } + } + `); + return terrasoApi + .requestGraphQL(query, { slug, storyMapId }) + .then(_.get('storyMaps.edges[0].node')) + .then(storyMap => storyMap || Promise.reject('not_found')) + .then(storyMap => ({ + ..._.omit(['membershipList', 'configuration'], storyMap), + config: storyMap.publishedConfiguration + ? JSON.parse(storyMap.publishedConfiguration) + : JSON.parse(storyMap.configuration), + memberships: extractMemberships(storyMap.membershipList), + accountMembership: extractAccountMembership(storyMap.membershipList), + })); +}; + +export const fetchStoryMapForm = ({ slug, storyMapId }) => { + const query = graphql(` + query fetchStoryMapForm($slug: String!, $storyMapId: String!) { storyMaps(slug: $slug, storyMapId: $storyMapId) { edges { node { @@ -96,8 +126,9 @@ export const addStoryMap = async ({ storyMap, files }) => { const path = '/story-map/add/'; const storyMapForm = new FormData(); - storyMapForm.append('title', _.getOr('', 'config.title', storyMap).trim()); - storyMapForm.append('is_published', storyMap.published); + const title = _.get('config.title', storyMap); + storyMapForm.append('title', _.isEmpty(title) ? 'Untitled' : title.trim()); // TODO translate + storyMapForm.append('publish', storyMap.publish); storyMapForm.append('configuration', JSON.stringify(storyMap.config)); Object.keys(files).forEach((fileId, index) => { const file = files[fileId].file; @@ -118,7 +149,7 @@ export const updateStoryMap = async ({ storyMap, files }) => { const storyMapForm = new FormData(); storyMapForm.append('id', storyMap.id); storyMapForm.append('title', _.getOr('', 'config.title', storyMap).trim()); - storyMapForm.append('is_published', storyMap.published); + storyMapForm.append('publish', storyMap.publish); storyMapForm.append('configuration', JSON.stringify(storyMap.config)); Object.keys(files).forEach((fileId, index) => { const file = files[fileId].file; diff --git a/src/storyMap/storyMapSlice.js b/src/storyMap/storyMapSlice.js index ab9c0938e..81c9be094 100644 --- a/src/storyMap/storyMapSlice.js +++ b/src/storyMap/storyMapSlice.js @@ -26,6 +26,7 @@ const initialState = { form: { fetching: true, saving: false, + error: false, data: null, }, view: { @@ -65,45 +66,19 @@ export const fetchStoryMap = createAsyncThunk( ); export const fetchStoryMapForm = createAsyncThunk( 'storyMap/fetchStoryMapForm', - storyMapService.fetchStoryMap + storyMapService.fetchStoryMapForm ); export const addStoryMap = createAsyncThunk( 'storyMap/addStoryMap', storyMapService.addStoryMap, - (storyMap, { storyMap: { config, published } }) => ({ - severity: 'success', - content: 'storyMap.added_story_map', - params: { - title: config.title, - context: published ? 'published' : 'draft', - }, - }), - true, - ({ message, input }) => - _.set( - 'params.context', - _.get('storyMap.published', input) ? 'published' : 'draft', - message - ) + null, + false ); export const updateStoryMap = createAsyncThunk( 'storyMap/updateStoryMap', storyMapService.updateStoryMap, - (storyMap, { storyMap: { config, published } }) => ({ - severity: 'success', - content: 'storyMap.update_story_map', - params: { - title: config.title, - context: published ? 'published' : 'draft', - }, - }), - true, - ({ message, input }) => - _.set( - 'params.context', - _.get('storyMap.published', input) ? 'published' : 'draft', - message - ) + null, + false ); export const deleteStoryMap = createAsyncThunk( 'storyMap/deleteStoryMap', @@ -272,9 +247,18 @@ const storyMapSlice = createSlice({ builder.addCase(addStoryMap.rejected, _.set('form.saving', false)); builder.addCase(addStoryMap.fulfilled, _.set('form.saving', false)); - builder.addCase(updateStoryMap.pending, _.set('form.saving', true)); - builder.addCase(updateStoryMap.rejected, _.set('form.saving', false)); - builder.addCase(updateStoryMap.fulfilled, _.set('form.saving', false)); + builder.addCase( + updateStoryMap.pending, + _.flow(_.set('form.saving', true), _.set('form.error', false)) + ); + builder.addCase( + updateStoryMap.rejected, + _.flow(_.set('form.saving', false), _.set('form.error', true)) + ); + builder.addCase( + updateStoryMap.fulfilled, + _.flow(_.set('form.saving', false), _.set('form.error', false)) + ); builder.addCase(deleteStoryMap.pending, (state, action) => _.set(`delete.${action.meta.arg.storyMap.id}.deleting`, true, state)