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:
@@ -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;
|
||||
|
||||
87
app/assets/javascripts/ui_models/app_state/features_state.ts
Normal file
87
app/assets/javascripts/ui_models/app_state/features_state.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user