Skip to content

Commit

Permalink
Feat: Unsaved Changes Modal (#2488)
Browse files Browse the repository at this point in the history
* refactor(ds): update EditProfile component

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* feat(ds): add reusable component for unsaved changes modal

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* feat(profile): add unsaved changes extension

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* feat(profile): connect extension

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(ds): update EditInterests component

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(extensions): update extension

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(extensions): connect extension

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* chore(profile): update modal name

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* feat(typings): update routing plugin type

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* feat(app - loader): update RoutingPlugin class methods

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* chore(libs): update comment

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* feat(antenna): show unsaved changes modal on beam editor

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* test(utils): update data generators

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(antenna): use singleSpa

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(apps): move components from DS into profile app

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* chore(apps): remove redundant file

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* feat(profile): update unsave changes modal flow

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* chore(apps): update props

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(app-loader): show loading indicator only when navigation is not canceled.

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* chore(typings): update typings

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(app-loader): move logic into app-loader

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* refactor(apps): update pages

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

* chore(tests): update data generator

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>

---------

Signed-off-by: Josemaria Nriagu <josemaria.nriagu@akasha.world>
  • Loading branch information
josenriagu authored Nov 29, 2024
1 parent 4087b5a commit 6774707
Show file tree
Hide file tree
Showing 17 changed files with 322 additions and 60 deletions.
61 changes: 57 additions & 4 deletions extensions/apps/antenna/src/extensions/beam-editor/beam-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef, ChangeEvent, KeyboardEvent } from 'react';
import React, { useEffect, useState, useRef, ChangeEvent, KeyboardEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { hasOwn, useAkashaStore, useRootComponentProps } from '@akashaorg/ui-awf-hooks';
import { type ContentBlock } from '@akashaorg/typings/lib/ui';
Expand All @@ -10,6 +10,7 @@ import Pill from '@akashaorg/design-system-core/lib/components/Pill';
import SearchBar from '@akashaorg/design-system-components/lib/components/SearchBar';
import Stack from '@akashaorg/design-system-core/lib/components/Stack';
import Text from '@akashaorg/design-system-core/lib/components/Text';
import UnsavedChangesModal from '@akashaorg/design-system-components/lib/components/UnsavedChangesModal';
import { EditorBlockExtension } from '@akashaorg/ui-lib-extensions/lib/react/content-block';
import { Header } from './header';
import { Footer } from './footer';
Expand All @@ -27,12 +28,15 @@ export const BeamEditor: React.FC = () => {
const [errorMessage, setErrorMessage] = useState(null);
const [isNsfw, setIsNsfw] = useState(false);
const [nsfwBlocks, setNsfwBlocks] = useState(new Map<number, boolean>());
const [disablePublishing, setDisablePublishing] = useState(true);
const [newUrl, setNewUrl] = useState<string | null>(null);

const bottomRef = useRef<HTMLDivElement>(null);

const { t } = useTranslation('app-antenna');

const { getCorePlugins } = useRootComponentProps();
const { singleSpa, cancelNavigation, getCorePlugins } = useRootComponentProps();

/*
* get the logged-in user info and info about their profile's NSFW property
*/
Expand Down Expand Up @@ -70,6 +74,12 @@ export const BeamEditor: React.FC = () => {

const { akashaProfile: profileData } =
data?.node && hasOwn(data.node, 'akashaProfile') ? data.node : { akashaProfile: null };

const disableBeamPublishing = useMemo(
() => isPublishing || disablePublishing,
[disablePublishing, isPublishing],
);

useEffect(() => {
if (profileData?.nsfw) {
setIsNsfw(true);
Expand Down Expand Up @@ -105,6 +115,27 @@ export const BeamEditor: React.FC = () => {
}
}, [blocksInUse]);

useEffect(() => {
let navigationUnsubscribe: () => void;
/**
* when beam publishing is not disabled;
* 1. call cancel navigation method from routing plugin
* 2. set the new url from the callback fn.
*/
if (!disableBeamPublishing) {
navigationUnsubscribe = cancelNavigation(!disableBeamPublishing, url => {
setNewUrl(url);
});
}

return () => {
if (typeof navigationUnsubscribe === 'function') {
navigationUnsubscribe();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disableBeamPublishing]);

const onBlockSelectAfter = (newSelection: ContentBlock) => {
if (!newSelection?.propertyType) {
return;
Expand Down Expand Up @@ -234,7 +265,6 @@ export const BeamEditor: React.FC = () => {
setUiState('editor');
};

const [disablePublishing, setDisablePublishing] = useState(false);
const blocksWithActiveNsfw = [...nsfwBlocks].filter(([, value]) => !!value);

useEffect(() => {
Expand Down Expand Up @@ -264,8 +294,31 @@ export const BeamEditor: React.FC = () => {
});
}, [blocksInUse, focusedBlock]);

const handleLeavePage = () => {
// reset states
setDisablePublishing(true);
setNewUrl(null);
// navigate away from editor to the desired url using singleSpa.
singleSpa.navigateToUrl(newUrl);
};

const handleModalClose = () => setNewUrl(null);

return (
<Card padding={0} customStyle="divide(y grey9 dark:grey3) h-[80vh] justify-between">
{!!newUrl && (
<UnsavedChangesModal
showModal={!!newUrl}
cancelButtonLabel={t('Cancel')}
leavePageButtonLabel={t('Leave page')}
title={t('Unsaved changes')}
description={t(
"Are you sure you want to leave this page? The changes you've made will not be saved.",
)}
handleModalClose={handleModalClose}
handleLeavePage={handleLeavePage}
/>
)}
<Header
uiState={uiState}
addTagsLabel={t('Add Tags')}
Expand Down Expand Up @@ -440,7 +493,7 @@ export const BeamEditor: React.FC = () => {
blocksNumber={blocksInUse.length}
disableAddBlock={blocksInUse.length === maxAllowedBlocks}
disableTagsSave={isPublishing || JSON.stringify(newTags) === JSON.stringify(editorTags)}
disableBeamPublishing={isPublishing || disablePublishing}
disableBeamPublishing={disableBeamPublishing}
handleClickTags={handleTagsBtn}
handleClickSave={handleClickSave}
handleClickCancel={handleClickCancel}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import React, { useCallback, useEffect, useState } from 'react';
import { apply, tw } from '@twind/core';
import { useTranslation } from 'react-i18next';
import { ProfileLabeled } from '@akashaorg/typings/lib/sdk/graphql-types-new';
import AutoComplete from '@akashaorg/design-system-core/lib/components/AutoComplete';
import Button from '@akashaorg/design-system-core/lib/components/Button';
import {
CheckIcon,
XMarkIcon,
} from '@akashaorg/design-system-core/lib/components/Icon/hero-icons-outline';
import Button from '@akashaorg/design-system-core/lib/components/Button';
import Pill from '@akashaorg/design-system-core/lib/components/Pill';
import Stack from '@akashaorg/design-system-core/lib/components/Stack';
import Text from '@akashaorg/design-system-core/lib/components/Text';
import { apply, tw } from '@twind/core';
import { ButtonType } from '../types/common.types';
import { ProfileLabeled } from '@akashaorg/typings/lib/sdk/graphql-types-new';
import { ButtonType } from '@akashaorg/design-system-components/lib/components/types/common.types';
import UnsavedChangesModal from '@akashaorg/design-system-components/lib/components/UnsavedChangesModal';
import { useRootComponentProps } from '@akashaorg/ui-awf-hooks';

export type EditInterestsProps = {
title: string;
Expand Down Expand Up @@ -62,8 +65,12 @@ const EditInterests: React.FC<EditInterestsProps> = ({
const [myActiveInterests, setMyActiveInterests] = useState(new Set(myInterests));
const [allMyInterests, setAllMyInterests] = useState(new Set(myInterests));
const [tags, setTags] = useState(new Set<string>());
const [newUrl, setNewUrl] = useState<string | null>(null);
const [isDisabled, setIsDisabled] = useState<boolean>(true);
const { t } = useTranslation('app-profile');
const { singleSpa, cancelNavigation } = useRootComponentProps();

React.useEffect(() => {
useEffect(() => {
setMyActiveInterests(new Set(myInterests));
setAllMyInterests(new Set(myInterests));
}, [myInterests]);
Expand Down Expand Up @@ -97,6 +104,7 @@ const EditInterests: React.FC<EditInterestsProps> = ({
!!query;

useEffect(() => {
setIsDisabled(!isFormDirty);
if (onFormDirty) onFormDirty(isFormDirty);
}, [isFormDirty, onFormDirty]);

Expand All @@ -108,6 +116,7 @@ const EditInterests: React.FC<EditInterestsProps> = ({
},
[allMyInterests],
);

const getNewInterest = useCallback(() => {
if (query) {
const foundInterest = findInterest(query);
Expand All @@ -120,8 +129,47 @@ const EditInterests: React.FC<EditInterestsProps> = ({

const maximumInterestsSelected = myActiveInterests.size + tagsSize >= maxInterests;

useEffect(() => {
let navigationUnsubscribe: () => void;
if (!isDisabled) {
navigationUnsubscribe = cancelNavigation(!isDisabled, url => {
setNewUrl(url);
});
}

return () => {
if (typeof navigationUnsubscribe === 'function') {
navigationUnsubscribe();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisabled]);

const handleLeavePage = () => {
// reset states
setIsDisabled(true);
setNewUrl(null);
// navigate away from editor to the desired url using singleSpa.
singleSpa.navigateToUrl(newUrl);
};

const handleModalClose = () => setNewUrl(null);

return (
<form className={tw(apply`h-full ${customStyle}`)}>
{!!newUrl && (
<UnsavedChangesModal
showModal={!!newUrl}
cancelButtonLabel={t('Cancel')}
leavePageButtonLabel={t('Leave page')}
title={t('Unsaved changes')}
description={t(
"Are you sure you want to leave this page? The changes you've made will not be saved.",
)}
handleModalClose={handleModalClose}
handleLeavePage={handleLeavePage}
/>
)}
<Stack direction="column" justify="between" spacing="gap-y-11" customStyle="h-full">
<Stack direction="column">
<Stack direction="row" align="center" spacing="gap-x-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Card from '@akashaorg/design-system-core/lib/components/Card';
import Stack from '@akashaorg/design-system-core/lib/components/Stack';
import Text from '@akashaorg/design-system-core/lib/components/Text';
import List, { ListProps } from '@akashaorg/design-system-core/lib/components/List';
import ImageModal from '../../../ImageModal';
import ImageModal from '@akashaorg/design-system-components/lib/components/ImageModal';
import {
ArrowUpOnSquareIcon,
PencilIcon,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import TextField from '@akashaorg/design-system-core/lib/components/TextField';
import { Controller, Control } from 'react-hook-form';
import { Header, HeaderProps } from './Header';
import { EditProfileFormValues } from '../types';
import { ButtonType } from '../../types/common.types';
import { ButtonType } from '@akashaorg/design-system-components/lib/components/types/common.types';

const MAX_BIO_LENGTH = 200;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import React, { SyntheticEvent, useMemo } from 'react';
import React, { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import * as z from 'zod';
import Button from '@akashaorg/design-system-core/lib/components/Button';
import Stack from '@akashaorg/design-system-core/lib/components/Stack';
import { SocialLinks, SocialLinksProps } from './SocialLinks';
import { apply, tw } from '@twind/core';
import { useTranslation } from 'react-i18next';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { General, GeneralProps } from './General';
import {
EditProfileFormValues,
isFormExcludingAllExceptLinksDirty,
isFormWithExceptionOfLinksDirty,
} from './types';
import { ButtonType } from '../types/common.types';
import { InputType, NSFW } from '../NSFW';
import { PublishProfileData } from '@akashaorg/typings/lib/ui';
import { useRootComponentProps } from '@akashaorg/ui-awf-hooks';
import Button from '@akashaorg/design-system-core/lib/components/Button';
import Stack from '@akashaorg/design-system-core/lib/components/Stack';
import { InputType, NSFW } from '@akashaorg/design-system-components/lib/components/NSFW';
import UnsavedChangesModal from '@akashaorg/design-system-components/lib/components/UnsavedChangesModal';
import { ButtonType } from '@akashaorg/design-system-components/lib/components/types/common.types';
import { General, GeneralProps } from './General';
import { SocialLinks, SocialLinksProps } from './SocialLinks';
import { isFormExcludingAllExceptLinksDirty, isFormWithExceptionOfLinksDirty } from './utils';
import { EditProfileFormValues } from './types';

const MIN_NAME_CHARACTERS = 3;

Expand All @@ -29,6 +29,10 @@ type GeneralFormProps = Pick<GeneralProps, 'header' | 'name' | 'bio'>;

export type EditProfileProps = {
defaultValues?: PublishProfileData;
/**
* modifying the handleClick to have an optional 'canSave' param.
* This determines when the cancel button click handler should show the unsaved changes modal.
*/
cancelButton: ButtonType;
saveButton: {
label: string;
Expand Down Expand Up @@ -62,15 +66,24 @@ const EditProfile: React.FC<EditProfileProps> = ({
linkLabel,
addNewLinkButtonLabel,
}) => {
const { control, setValue, getValues, formState } = useForm<EditProfileFormValues>({
const [newUrl, setNewUrl] = useState<string | null>(null);
const [isDisabled, setIsDisabled] = useState<boolean>(true);
const [isFormValid, setIsFormValid] = useState<boolean>(false);
const { t } = useTranslation('app-profile');
const { singleSpa, cancelNavigation } = useRootComponentProps();
const {
control,
setValue,
getValues,
formState: { dirtyFields, errors },
} = useForm<EditProfileFormValues>({
defaultValues: {
...defaultValues,
links: defaultValues.links.map(link => ({ id: crypto.randomUUID(), href: link })),
},
resolver: zodResolver(schema),
mode: 'onChange',
});
const { dirtyFields, errors } = formState;

const links = useWatch({ name: 'links', control });

Expand All @@ -82,22 +95,68 @@ const EditProfile: React.FC<EditProfileProps> = ({
const isFormDirty =
isFormWithExceptionOfLinksDirty(dirtyFields) || formExcludingAllExceptLinksDirty;

const isValid = !Object.keys(errors).length;

const onSave = (event: SyntheticEvent) => {
event.preventDefault();
const formValues = getValues();

if (isValid && isFormDirty) {
if (isFormValid && isFormDirty) {
saveButton.handleClick({
...formValues,
links: formValues.links?.map(link => link.href?.trim())?.filter(link => link) || [],
});
// reset state, to prevent re-triggering unsaved changes modal
setIsDisabled(true);
}
};

useEffect(() => {
const isValid = !Object.keys(errors).length;
const buttonDisabled = isValid ? !isFormDirty : true;
setIsFormValid(isValid);
setIsDisabled(buttonDisabled);
}, [dirtyFields, errors, isFormDirty]);

useEffect(() => {
let navigationUnsubscribe: () => void;
if (!isDisabled) {
navigationUnsubscribe = cancelNavigation(!isDisabled, url => {
setNewUrl(url);
});
}

return () => {
if (typeof navigationUnsubscribe === 'function') {
navigationUnsubscribe();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisabled]);

const handleLeavePage = () => {
// reset states
setIsDisabled(true);
setNewUrl(null);
// navigate away from editor to the desired url using singleSpa.
singleSpa.navigateToUrl(newUrl);
};

const handleModalClose = () => setNewUrl(null);

return (
<form data-testid="edit-profile" onSubmit={onSave} className={tw(apply`h-full ${customStyle}`)}>
{!!newUrl && (
<UnsavedChangesModal
showModal={!!newUrl}
cancelButtonLabel={t('Cancel')}
leavePageButtonLabel={t('Leave page')}
title={t('Unsaved changes')}
description={t(
"Are you sure you want to leave this page? The changes you've made will not be saved.",
)}
handleModalClose={handleModalClose}
handleLeavePage={handleLeavePage}
/>
)}
<Stack direction="column" spacing="gap-y-6">
<General
header={header}
Expand Down Expand Up @@ -136,7 +195,7 @@ const EditProfile: React.FC<EditProfileProps> = ({
variant="primary"
label={saveButton.label}
loading={saveButton.loading}
disabled={isValid ? !isFormDirty : true}
disabled={isDisabled}
onClick={onSave}
type="submit"
/>
Expand Down
Loading

0 comments on commit 6774707

Please sign in to comment.