feat: implement tags folder as experimental feature (#788)

* feat: add tag folders support basics

* feat: add draggability to tags

* feat: add drag & drop draft

* feat: fold folders

* fix: do not select on fold / unfold tags

* style: clean the isValidTag call

* feat: add native folder toggle

* feat: add touch mobile support

* ui: add nicer design & icons

* style: render full-width tag items

* feat: nicer looking dropzone

* style: fix arguments

* fix: tag template rendering in list items

* feat: tag can be dragged over the whole item

* fix: cancel / reset title after save

* fix: disable drag completely when needed

* fix: invalid tag parents

* feat: add paying feature

* feat: with paid feature tooltip

* feat: tag has a plus feature

* feat: add premium modal

* style: simplif code

* refactor: extract feature_state & simplif code

* fix: icons and icons svg

* style: remove comment

* feat: tag folders naming

* feat: use the feature notification

* fix: tag folders copy

* style: variable names

* style: remove & clean comments

* refactor: remove is-mobile library

* feat: tags folder experimental (#10)

* feat: hide native folders behind experimental flag

* fix: better tags resizing

* fix: merge global window

* style: rename params

* refactor: remove level of indirection in feature toggle

* feat: recursively add tags to note on create (#9)

* fix: use add tags folder hierarchy & isTemplateItem (#13)

* fix: use new snjs add tag hierarchy

* fix: use new snjs isTemplateItem

* feat: tags folder premium (#774)

* feat: upgrade premium in tags section

refactor: move TagsSection to react

feat: show premium on Tag section

feat: keep drag and drop features always active

fix: drag & drop tweak with premium

feat: premium messages

fix: remove fill in svg icons

fix: change tag list color (temporary)

style: remove dead code

refactor: clarify names and modules

fix: draggable behind feature toggle

feat: add button in TagSection & design

* feat: fix features loading with app state (#775)

* fix: distinguish between app launch and start

* fix: update state for footer too

* fix: wait for application launch event

Co-authored-by: Laurent Senta <laurent@singulargarden.com>

* feat: tags folder with folder text design (#776)

* feat: add folder text

* fix: sn stylekit colors

* fix: root drop zone

* chore: upgrade stylekit

* fix: hide dropzone when feature is disabled

* chore: bump versions now that they are released

Co-authored-by: Mo <me@bitar.io>

* feat: tags folder design review (#785)

* fix: upgrade design after review

* fix: tweak dropzone

* fix: sync after assign parent

* fix: tsc error on build

* fix: vertical center the fold arrows

* fix: define our own hoist for react-dnd

* feat: hide fold when there are no folders

* fix: show children usability + resize UI

* fix: use old colors for now, theme compat

* fix: tweak alignment and add title

* fix: meta offset with folders

* fix: tweak tag size

* fix: observable setup

* fix: use link-off icon on dropzone

* fix: more tweak on text sizes

Co-authored-by: Mo <me@bitar.io>
This commit is contained in:
Laurent Senta
2021-12-23 14:34:02 +01:00
committed by GitHub
parent 4a5a202a37
commit 237cd91acd
33 changed files with 1048 additions and 221 deletions

View File

@@ -22,6 +22,7 @@ import {
runInAction,
} from 'mobx';
import { ActionsMenuState } from './actions_menu_state';
import { FeaturesState } from './features_state';
import { NotesState } from './notes_state';
import { NotesViewState } from './notes_view_state';
import { NoteTagsState } from './note_tags_state';
@@ -57,8 +58,8 @@ export enum EventSource {
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>;
export class AppState {
readonly enableUnfinishedFeatures: boolean = (window as any)
?._enable_unfinished_features;
readonly enableUnfinishedFeatures: boolean =
window?._enable_unfinished_features;
$rootScope: ng.IRootScopeService;
$timeout: ng.ITimeoutService;
@@ -86,6 +87,7 @@ export class AppState {
readonly sync = new SyncState();
readonly searchOptions: SearchOptionsState;
readonly notes: NotesState;
readonly features: FeaturesState;
readonly tags: TagsState;
readonly notesView: NotesViewState;
isSessionsModalVisible = false;
@@ -115,7 +117,12 @@ export class AppState {
this,
this.appEventObserverRemovers
);
this.tags = new TagsState(application, this.appEventObserverRemovers);
this.features = new FeaturesState(application);
this.tags = new TagsState(
application,
this.appEventObserverRemovers,
this.features
);
this.noAccountWarning = new NoAccountWarningState(
application,
this.appEventObserverRemovers
@@ -187,6 +194,7 @@ export class AppState {
this.unsubApp = undefined;
this.observers.length = 0;
this.appEventObserverRemovers.forEach((remover) => remover());
this.features.deinit();
this.appEventObserverRemovers.length = 0;
if (this.rootScopeCleanup1) {
this.rootScopeCleanup1();
@@ -430,6 +438,10 @@ export class AppState {
}
public async createNewTag() {
if (this.templateTag) {
return;
}
const newTag = (await this.application.createTemplateItem(
ContentType.Tag
)) as SNTag;

View File

@@ -0,0 +1,87 @@
import {
ApplicationEvent,
FeatureIdentifier,
FeatureStatus,
} from '@standardnotes/snjs';
import { computed, makeObservable, observable, runInAction } from 'mobx';
import { WebApplication } from '../application';
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders';
export const TAG_FOLDERS_FEATURE_TOOLTIP =
'A Plus or Pro plan is required to enable Tag folders.';
/**
* Holds state for premium/non premium features for the current user features,
* and eventually for in-development features (feature flags).
*/
export class FeaturesState {
readonly enableUnfinishedFeatures: boolean =
window?._enable_unfinished_features;
_hasFolders = false;
private unsub: () => void;
constructor(private application: WebApplication) {
this._hasFolders = this.hasNativeFolders();
makeObservable(this, {
_hasFolders: observable,
hasFolders: computed,
enableNativeFoldersFeature: computed,
});
this.unsub = this.application.addEventObserver(async (eventName) => {
switch (eventName) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
runInAction(() => {
this._hasFolders = this.hasNativeFolders();
});
break;
default:
break;
}
});
}
public deinit() {
this.unsub();
}
public get enableNativeFoldersFeature(): boolean {
return this.enableUnfinishedFeatures;
}
public get hasFolders(): boolean {
return this._hasFolders;
}
public set hasFolders(hasFolders: boolean) {
if (!hasFolders) {
this._hasFolders = false;
return;
}
if (!this.hasNativeFolders()) {
this.application.alertService?.alert(
`${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.`
);
this._hasFolders = false;
return;
}
this._hasFolders = hasFolders;
}
private hasNativeFolders(): boolean {
if (!this.enableNativeFoldersFeature) {
return false;
}
const status = this.application.getFeatureStatus(
FeatureIdentifier.TagNesting
);
return status === FeatureStatus.Entitled;
}
}

View File

@@ -176,16 +176,9 @@ export class NoteTagsState {
async addTagToActiveNote(tag: SNTag): Promise<void> {
const { activeNote } = this;
if (activeNote) {
const parentChainTags = this.application.getTagParentChain(tag);
const tagsToAdd = [...parentChainTags, tag];
await Promise.all(
tagsToAdd.map(async (tag) => {
await this.application.changeItem(tag.uuid, (mutator) => {
mutator.addItemAsRelationship(activeNote);
});
})
);
await this.application.addTagHierarchyToNote(activeNote, tag);
this.application.sync();
this.reloadTags();
}

View File

@@ -1,13 +1,15 @@
import { ContentType, SNSmartTag, SNTag } from '@standardnotes/snjs';
import { ContentType, SNSmartTag, SNTag, UuidString } from '@standardnotes/snjs';
import {
action,
computed,
makeAutoObservable,
makeObservable,
observable,
runInAction,
runInAction
} from 'mobx';
import { WebApplication } from '../application';
import { FeaturesState } from './features_state';
export class TagsState {
tags: SNTag[] = [];
@@ -16,14 +18,20 @@ export class TagsState {
constructor(
private application: WebApplication,
appEventListeners: (() => void)[]
appEventListeners: (() => void)[],
private features: FeaturesState
) {
this.tagsCountsState = new TagsCountsState(this.application);
makeObservable(this, {
tags: observable,
smartTags: observable,
tags: observable.ref,
smartTags: observable.ref,
hasFolders: computed,
hasAtLeastOneFolder: computed,
assignParent: action,
rootTags: computed,
tagsCount: computed,
});
@@ -48,9 +56,68 @@ export class TagsState {
return this.tagsCountsState.counts[tag.uuid] || 0;
}
getChildren(tag: SNTag): SNTag[] {
if (!this.hasFolders) {
return [];
}
if (this.application.isTemplateItem(tag)) {
return [];
}
const children = this.application.getTagChildren(tag);
const childrenUuids = children.map((childTag) => childTag.uuid);
const childrenTags = this.tags.filter((tag) =>
childrenUuids.includes(tag.uuid)
);
return childrenTags;
}
isValidTagParent(parentUuid: UuidString, tagUuid: UuidString): boolean {
return this.application.isValidTagParent(parentUuid, tagUuid);
}
public async assignParent(
tagUuid: string,
parentUuid: string | undefined
): Promise<void> {
const tag = this.application.findItem(tagUuid) as SNTag;
const parent =
parentUuid && (this.application.findItem(parentUuid) as SNTag);
if (!parent) {
await this.application.unsetTagParent(tag);
} else {
await this.application.setTagParent(parent, tag);
}
await this.application.sync();
}
get rootTags(): SNTag[] {
if (!this.hasFolders) {
return this.tags;
}
return this.tags.filter((tag) => !this.application.getTagParent(tag));
}
get tagsCount(): number {
return this.tags.length;
}
public get hasFolders(): boolean {
return this.features.hasFolders;
}
public set hasFolders(hasFolders: boolean) {
this.features.hasFolders = hasFolders;
}
public get hasAtLeastOneFolder(): boolean {
return this.tags.some((tag) => !!this.application.getTagParent(tag));
}
}
/**

View File

@@ -1,14 +1,21 @@
import { SNNote, ContentType, PayloadSource, UuidString, TagMutator } from '@standardnotes/snjs';
import {
SNNote,
ContentType,
PayloadSource,
UuidString,
TagMutator,
SNTag,
} from '@standardnotes/snjs';
import { WebApplication } from './application';
import { NoteTagsState } from './app_state/note_tags_state';
export class Editor {
public note!: SNNote
private application: WebApplication
private _onNoteChange?: () => void
private _onNoteValueChange?: (note: SNNote, source?: PayloadSource) => void
private removeStreamObserver?: () => void
public isTemplateNote = false
public note!: SNNote;
private application: WebApplication;
private _onNoteChange?: () => void;
private _onNoteValueChange?: (note: SNNote, source?: PayloadSource) => void;
private removeStreamObserver?: () => void;
public isTemplateNote = false;
constructor(
application: WebApplication,
@@ -66,22 +73,15 @@ export class Editor {
* Reverts the editor to a blank state, removing any existing note from view,
* and creating a placeholder note.
*/
async reset(
noteTitle = '',
noteTag?: UuidString,
) {
const note = await this.application.createTemplateItem(
ContentType.Note,
{
text: '',
title: noteTitle,
references: []
}
) as SNNote;
async reset(noteTitle = '', noteTag?: UuidString) {
const note = (await this.application.createTemplateItem(ContentType.Note, {
text: '',
title: noteTitle,
references: [],
})) as SNNote;
if (noteTag) {
await this.application.changeItem<TagMutator>(noteTag, (m) => {
m.addItemAsRelationship(note);
});
const tag = this.application.findItem(noteTag) as SNTag;
await this.application.addTagHierarchyToNote(note, tag);
}
if (!this.isTemplateNote || this.note.title !== note.title) {
this.setNote(note as SNNote, true);
@@ -106,7 +106,9 @@ export class Editor {
* Register to be notified when the editor's note's values change
* (and thus a new object reference is created)
*/
public onNoteValueChange(callback: (note: SNNote, source?: PayloadSource) => void) {
public onNoteValueChange(
callback: (note: SNNote, source?: PayloadSource) => void
) {
this._onNoteValueChange = callback;
}