feat: add migration pref pane (#825)

* feat: add migration pane

* style: clean imports

* removeme: beta version

* fix: do no show dropzone when tag has no parent

* fix: hide the Folders toggle

* fix: hide migrations option when user has no folders

* fix: add delimiter on Folders mark

* removeme: bump beta

* fix: remove component viewer for tag folders

* removeme: bump beta

* chore(deps): snjs
This commit is contained in:
Laurent Senta
2022-02-01 02:18:14 +01:00
committed by GitHub
parent ed729ab4ef
commit 3c0bc79768
12 changed files with 181 additions and 145 deletions

View File

@@ -1,4 +1,3 @@
import { ComponentView } from '@/components/ComponentView';
import { SmartTagsSection } from '@/components/Tags/SmartTagsSection'; import { SmartTagsSection } from '@/components/Tags/SmartTagsSection';
import { TagsSection } from '@/components/Tags/TagsSection'; import { TagsSection } from '@/components/Tags/TagsSection';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
@@ -6,13 +5,7 @@ import { PANEL_NAME_NAVIGATION } from '@/views/constants';
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'; import { ApplicationEvent, PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { PremiumModalProvider } from './Premium'; import { PremiumModalProvider } from './Premium';
import { import {
PanelSide, PanelSide,
@@ -28,7 +21,6 @@ type Props = {
export const Navigation: FunctionComponent<Props> = observer( export const Navigation: FunctionComponent<Props> = observer(
({ application }) => { ({ application }) => {
const appState = useMemo(() => application.getAppState(), [application]); const appState = useMemo(() => application.getAppState(), [application]);
const componentViewer = appState.foldersComponentViewer;
const enableNativeSmartTagsFeature = const enableNativeSmartTagsFeature =
appState.features.enableNativeSmartTagsFeature; appState.features.enableNativeSmartTagsFeature;
const [ref, setRef] = useState<HTMLDivElement | null>(); const [ref, setRef] = useState<HTMLDivElement | null>();
@@ -72,42 +64,30 @@ export const Navigation: FunctionComponent<Props> = observer(
data-aria-label="Navigation" data-aria-label="Navigation"
ref={setRef} ref={setRef}
> >
{componentViewer ? ( <div id="navigation-content" className="content">
<div className="component-view-container"> <div className="section-title-bar">
<div className="component-view"> <div className="section-title-bar-header">
<ComponentView <div className="sk-h3 title">
componentViewer={componentViewer} <span className="sk-bold">Views</span>
application={application}
appState={appState}
/>
</div>
</div>
) : (
<div id="navigation-content" className="content">
<div className="section-title-bar">
<div className="section-title-bar-header">
<div className="sk-h3 title">
<span className="sk-bold">Views</span>
</div>
{!enableNativeSmartTagsFeature && (
<div
className="sk-button sk-secondary-contrast wide"
onClick={onCreateNewTag}
title="Create a new tag"
>
<div className="sk-label">
<i className="icon ion-plus add-button" />
</div>
</div>
)}
</div> </div>
</div> {!enableNativeSmartTagsFeature && (
<div className="scrollable"> <div
<SmartTagsSection appState={appState} /> className="sk-button sk-secondary-contrast wide"
<TagsSection appState={appState} /> onClick={onCreateNewTag}
title="Create a new tag"
>
<div className="sk-label">
<i className="icon ion-plus add-button" />
</div>
</div>
)}
</div> </div>
</div> </div>
)} <div className="scrollable">
<SmartTagsSection appState={appState} />
<TagsSection appState={appState} />
</div>
</div>
{ref && ( {ref && (
<PanelResizer <PanelResizer
collapsable={true} collapsable={true}

View File

@@ -8,6 +8,7 @@ import {
import { import {
ComponentArea, ComponentArea,
ContentType, ContentType,
FeatureIdentifier,
SNComponent, SNComponent,
SNTheme, SNTheme,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
@@ -107,10 +108,11 @@ export const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
const reloadToggleableComponents = useCallback(() => { const reloadToggleableComponents = useCallback(() => {
const toggleableComponents = ( const toggleableComponents = (
application.getDisplayableItems(ContentType.Component) as SNComponent[] application.getDisplayableItems(ContentType.Component) as SNComponent[]
).filter((component) => ).filter(
[ComponentArea.EditorStack, ComponentArea.TagsList].includes( (component) =>
component.area [ComponentArea.EditorStack, ComponentArea.TagsList].includes(
) component.area
) && component.identifier !== FeatureIdentifier.FoldersComponent
); );
setToggleableComponents(toggleableComponents); setToggleableComponents(toggleableComponents);
}, [application]); }, [application]);

View File

@@ -1,9 +1,6 @@
import { Icon } from '@/components/Icon'; import { Icon } from '@/components/Icon';
import { usePremiumModal } from '@/components/Premium'; import { usePremiumModal } from '@/components/Premium';
import { import { FeaturesState } from '@/ui_models/app_state/features_state';
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
} from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state'; import { TagsState } from '@/ui_models/app_state/tags_state';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useDrop } from 'react-dnd'; import { useDrop } from 'react-dnd';
@@ -22,8 +19,8 @@ export const RootTagDropZone: React.FC<Props> = observer(
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>( const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({ () => ({
accept: ItemTypes.TAG, accept: ItemTypes.TAG,
canDrop: () => { canDrop: (item) => {
return true; return tagsState.hasParent(item.uuid);
}, },
drop: (item) => { drop: (item) => {
tagsState.assignParent(item.uuid, undefined); tagsState.assignParent(item.uuid, undefined);

View File

@@ -52,7 +52,7 @@ export const TagsSectionTitle: FunctionComponent<Props> = observer(
className="ml-1 sk-bold color-grey-2 cursor-pointer" className="ml-1 sk-bold color-grey-2 cursor-pointer"
onClick={showPremiumAlert} onClick={showPremiumAlert}
> >
Folders &middot; Folders
</label> </label>
</Tooltip> </Tooltip>
</div> </div>

View File

@@ -91,22 +91,27 @@ type ListElementProps = {
}; };
export const MenuItemListElement: FunctionComponent<ListElementProps> = export const MenuItemListElement: FunctionComponent<ListElementProps> =
forwardRef(({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => { forwardRef(
const child = children as VNode<unknown>; (
{ children, isFirstMenuItem }: ListElementProps,
ref: Ref<HTMLLIElement>
) => {
const child = children as VNode<unknown>;
return ( return (
<li className="list-style-none" role="none" ref={ref}> <li className="list-style-none" role="none" ref={ref}>
{{ {{
...child, ...child,
props: { props: {
...(child.props ? { ...child.props } : {}), ...(child.props ? { ...child.props } : {}),
...(child.type === MenuItem ...(child.type === MenuItem
? { ? {
tabIndex: isFirstMenuItem ? 0 : -1, tabIndex: isFirstMenuItem ? 0 : -1,
} }
: {}), : {}),
}, },
}} }}
</li> </li>
); );
}); }
);

View File

@@ -16,7 +16,9 @@ export const MenuItem: FunctionComponent<Props> = ({
onClick, onClick,
}) => ( }) => (
<div <div
className={`preferences-menu-item select-none ${selected ? 'selected' : ''}`} className={`preferences-menu-item select-none ${
selected ? 'selected' : ''
}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onClick(); onClick();

View File

@@ -6,24 +6,26 @@ import { ErrorReporting, Tools, Defaults } from './general-segments';
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments'; import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
import { Advanced } from '@/preferences/panes/account'; import { Advanced } from '@/preferences/panes/account';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Migrations } from './general-segments/Migrations';
interface GeneralProps { interface GeneralProps {
appState: AppState; appState: AppState;
application: WebApplication; application: WebApplication;
extensionsLatestVersions: ExtensionsLatestVersions, extensionsLatestVersions: ExtensionsLatestVersions;
} }
export const General: FunctionComponent<GeneralProps> = observer( export const General: FunctionComponent<GeneralProps> = observer(
({ ({ appState, application, extensionsLatestVersions }) => (
appState,
application,
extensionsLatestVersions
}) => (
<PreferencesPane> <PreferencesPane>
<Tools application={application} /> <Tools application={application} />
<Defaults application={application} /> <Defaults application={application} />
<ErrorReporting appState={appState} /> <ErrorReporting appState={appState} />
<Advanced application={application} appState={appState} extensionsLatestVersions={extensionsLatestVersions} /> <Migrations application={application} appState={appState} />
<Advanced
application={application}
appState={appState}
extensionsLatestVersions={extensionsLatestVersions}
/>
</PreferencesPane> </PreferencesPane>
) )
); );

View File

@@ -0,0 +1,103 @@
import { Button } from '@/components/Button';
import { Icon } from '@/components/Icon';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { FunctionComponent } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '../../components';
type Props = {
application: WebApplication;
appState: AppState;
};
export const CheckIcon: React.FC = () => {
return <Icon className="success min-w-4 min-h-4" type="check-bold" />;
};
const Migration3dot0dot0: FunctionComponent<Props> = ({ application }) => {
const [loading, setLoading] = useState(false);
const [complete, setComplete] = useState(false);
const [error, setError] = useState<unknown | null>(null);
const trigger = useCallback(() => {
setLoading(true);
setError(null);
setComplete(false);
application
.migrateTagDotsToHierarchy()
.then(() => {
setLoading(false);
setError(null);
setComplete(true);
})
.catch((error: unknown) => {
setLoading(false);
setError(error);
setComplete(false);
});
}, [application, setLoading, setError]);
return (
<>
<Subtitle>(3.0.0) Folders Component to Native Folders</Subtitle>
<Text>
This migration transform tags with "." in their title into a hierarchy
of parents. This lets your transform tag hierarchies created with the
folder component into native tag folders.
</Text>
<div className="flex flex-row items-center mt-3">
<Button
type="normal"
onClick={trigger}
className="m-2"
disabled={loading}
label="Run Now"
/>
{complete && (
<div className="ml-3">
<Text>Migration successful.</Text>
</div>
)}
{error && (
<div className="ml-3">
<Text>Something wrong happened. Please contact support.</Text>
</div>
)}
</div>
</>
);
};
export const Migrations: FunctionComponent<Props> = ({
application,
appState,
}) => {
const hasNativeFoldersEnabled = appState.features.enableNativeFoldersFeature;
if (!hasNativeFoldersEnabled) {
return null;
}
const hasFoldersFeature = appState.features.hasFolders;
if (!hasFoldersFeature) {
return null;
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Migrations</Title>
<div className="h-2 w-full" />
<Migration3dot0dot0 application={application} appState={appState} />
</PreferencesSegment>
</PreferencesGroup>
);
};

View File

@@ -5,18 +5,14 @@ import { AccountMenuState } from '@/ui_models/app_state/account_menu_state';
import { isDesktopApplication } from '@/utils'; import { isDesktopApplication } from '@/utils';
import { import {
ApplicationEvent, ApplicationEvent,
ComponentArea,
ContentType, ContentType,
DeinitSource, DeinitSource,
isPayloadSourceInternalChange, NoteViewController,
PayloadSource, PayloadSource,
PrefKey, PrefKey,
SNComponent,
SNNote, SNNote,
SNSmartTag, SNSmartTag,
ComponentViewer,
SNTag, SNTag,
NoteViewController,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import pull from 'lodash/pull'; import pull from 'lodash/pull';
import { import {
@@ -91,14 +87,11 @@ export class AppState {
readonly tags: TagsState; readonly tags: TagsState;
readonly notesView: NotesViewState; readonly notesView: NotesViewState;
public foldersComponentViewer?: ComponentViewer;
isSessionsModalVisible = false; isSessionsModalVisible = false;
private appEventObserverRemovers: (() => void)[] = []; private appEventObserverRemovers: (() => void)[] = [];
private readonly tagChangedDisposer: IReactionDisposer; private readonly tagChangedDisposer: IReactionDisposer;
private readonly foldersComponentViewerDisposer: () => void;
/* @ngInject */ /* @ngInject */
constructor(application: WebApplication, private bridge: Bridge) { constructor(application: WebApplication, private bridge: Bridge) {
@@ -157,8 +150,6 @@ export class AppState {
this.showBetaWarning = false; this.showBetaWarning = false;
} }
this.foldersComponentViewer = undefined;
makeObservable(this, { makeObservable(this, {
selectedTag: computed, selectedTag: computed,
@@ -170,14 +161,9 @@ export class AppState {
disableBetaWarning: action, disableBetaWarning: action,
openSessionsModal: action, openSessionsModal: action,
closeSessionsModal: action, closeSessionsModal: action,
foldersComponentViewer: observable.ref,
setFoldersComponent: action,
}); });
this.tagChangedDisposer = this.tagChangedNotifier(); this.tagChangedDisposer = this.tagChangedNotifier();
this.foldersComponentViewerDisposer =
this.subscribeToFoldersComponentChanges();
} }
deinit(source: DeinitSource): void { deinit(source: DeinitSource): void {
@@ -197,7 +183,6 @@ export class AppState {
document.removeEventListener('visibilitychange', this.onVisibilityChange); document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.onVisibilityChange = undefined; this.onVisibilityChange = undefined;
this.tagChangedDisposer(); this.tagChangedDisposer();
this.foldersComponentViewerDisposer();
} }
openSessionsModal(): void { openSessionsModal(): void {
@@ -302,51 +287,6 @@ export class AppState {
); );
} }
setFoldersComponent(component?: SNComponent) {
const foldersComponentViewer = this.foldersComponentViewer;
if (foldersComponentViewer) {
this.application.componentManager.destroyComponentViewer(
foldersComponentViewer
);
this.foldersComponentViewer = undefined;
}
if (component) {
this.foldersComponentViewer =
this.application.componentManager.createComponentViewer(
component,
undefined,
this.tags.onFoldersComponentMessage.bind(this.tags)
);
}
}
private subscribeToFoldersComponentChanges() {
return this.application.streamItems(
[ContentType.Component],
async (items, source) => {
if (
isPayloadSourceInternalChange(source) ||
source === PayloadSource.InitialObserverRegistrationPush
) {
return;
}
const components = items as SNComponent[];
const hasFoldersChange = !!components.find(
(component) => component.area === ComponentArea.TagsList
);
if (hasFoldersChange) {
const componentViewer = this.application.componentManager
.componentsForArea(ComponentArea.TagsList)
.find((component) => component.active);
this.setFoldersComponent(componentViewer);
}
}
);
}
public get selectedTag(): SNTag | SNSmartTag | undefined { public get selectedTag(): SNTag | SNSmartTag | undefined {
return this.tags.selected; return this.tags.selected;
} }

View File

@@ -195,6 +195,11 @@ export class TagsState {
return this.application.isValidTagParent(parentUuid, tagUuid); return this.application.isValidTagParent(parentUuid, tagUuid);
} }
public hasParent(tagUuid: UuidString): boolean {
const item = this.application.findItem(tagUuid);
return !!item && !!(item as SNTag).parentId;
}
public async assignParent( public async assignParent(
tagUuid: string, tagUuid: string,
futureParentUuid: string | undefined futureParentUuid: string | undefined

View File

@@ -84,7 +84,7 @@
"@reach/tooltip": "^0.16.2", "@reach/tooltip": "^0.16.2",
"@standardnotes/components": "1.4.4", "@standardnotes/components": "1.4.4",
"@standardnotes/features": "1.26.1", "@standardnotes/features": "1.26.1",
"@standardnotes/snjs": "2.45.0", "@standardnotes/snjs": "2.46.0",
"@standardnotes/settings": "^1.10.0", "@standardnotes/settings": "^1.10.0",
"@standardnotes/sncrypto-web": "1.6.0", "@standardnotes/sncrypto-web": "1.6.0",
"mobx": "^6.3.5", "mobx": "^6.3.5",

View File

@@ -2655,10 +2655,10 @@
buffer "^6.0.3" buffer "^6.0.3"
libsodium-wrappers "^0.7.9" libsodium-wrappers "^0.7.9"
"@standardnotes/snjs@2.45.0": "@standardnotes/snjs@2.46.0":
version "2.45.0" version "2.46.0"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.45.0.tgz#d123434b959d279af2ffe00acf2e9e6784cf497c" resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.46.0.tgz#7e4242df8cd0408fde243e1048536fa19ea6c5d9"
integrity sha512-gTlOG3wd4zYaBeReypQiz+ASEnVCKaB8kWtKF61nkV9j3vFgYh3krsvdhOi6lMXBk+CijEefeLhrmooOtv08Xg== integrity sha512-lKCW6/Q3HaDdaI6VJ31m6GyIy6TgHyGesdUQzbMFtZPEhHDjySqFCyIaQy0ZVdAMB7X/HFxREtxwAwG5wfVVvw==
dependencies: dependencies:
"@standardnotes/auth" "^3.15.3" "@standardnotes/auth" "^3.15.3"
"@standardnotes/common" "^1.8.0" "@standardnotes/common" "^1.8.0"