feat: create tag dialog (#814)

This commit is contained in:
boojack 2022-12-21 23:59:03 +08:00 committed by GitHub
parent e4a8a4d708
commit 68a77b6e1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 361 additions and 47 deletions

View File

@ -2,20 +2,85 @@ package server
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
metric "github.com/usememos/memos/plugin/metrics"
"github.com/labstack/echo/v4"
)
var tagRegexpList = []*regexp.Regexp{regexp.MustCompile(`^#([^\s#]+?) `), regexp.MustCompile(`^#([^\s#]+?)\s`), regexp.MustCompile(`[^\S]#([^\s#]+?)$`), regexp.MustCompile(`[^\S]#([^\s#]+?) `), regexp.MustCompile(` #([^\s#]+?) `)}
func (s *Server) registerTagRoutes(g *echo.Group) {
g.POST("/tag", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagUpsert := &api.TagUpsert{
CreatorID: userID,
}
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
}
if tagUpsert.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
}
tag, err := s.Store.UpsertTag(ctx, tagUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
}
s.Collector.Collect(ctx, &metric.Metric{
Name: "tag created",
})
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tag.Name)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tag response").SetInternal(err)
}
return nil
})
g.GET("/tag", func(c echo.Context) error {
ctx := c.Request().Context()
tagFind := &api.TagFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
tagFind.CreatorID = userID
}
if tagFind.CreatorID == 0 {
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
tagFind.CreatorID = currentUserID
}
tagList, err := s.Store.FindTagList(ctx, tagFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
}
tagNameList := []string{}
for _, tag := range tagList {
tagNameList = append(tagNameList, tag.Name)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tagNameList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tags response").SetInternal(err)
}
return nil
})
g.GET("/tag/suggestion", func(c echo.Context) error {
ctx := c.Request().Context()
contentSearch := "#"
normalRowStatus := api.Normal
@ -65,15 +130,42 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
}
return nil
})
g.DELETE("/tag/:tagName", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagName := c.Param("tagName")
if tagName == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name cannot be empty")
}
tagDelete := &api.TagDelete{
Name: tagName,
CreatorID: userID,
}
if err := s.Store.DeleteTag(ctx, tagDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Tag name not found: %s", tagName))
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagName)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
var tagRegexp = regexp.MustCompile(`#([^\s#]+)`)
func findTagListFromMemoContent(memoContent string) []string {
tagMapSet := make(map[string]bool)
for _, tagRegexp := range tagRegexpList {
for _, rawTag := range tagRegexp.FindAllString(memoContent, -1) {
tag := tagRegexp.ReplaceAllString(rawTag, "$1")
tagMapSet[tag] = true
}
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
for _, v := range matches {
tagName := v[1]
tagMapSet[tagName] = true
}
tagList := []string{}

View File

@ -31,11 +31,11 @@ func TestFindTagListFromMemoContent(t *testing.T) {
},
{
memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ",
want: []string{"tag1", "tag3", "tag4"},
want: []string{"tag1", "tag2", "tag3", "tag4"},
},
{
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
want: []string{"tag1", "tag3", "tag4"},
want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"},
},
}
for _, test := range tests {

View File

@ -87,7 +87,9 @@ func upsertTag(ctx context.Context, tx *sql.Tx, upsert *api.TagUpsert) (*tagRaw,
name, creator_id
)
VALUES (?, ?)
ON CONFLICT(name, creator_id) DO NOTHING
ON CONFLICT(name, creator_id) DO UPDATE
SET
name = EXCLUDED.name
RETURNING name, creator_id
`
var tagRaw tagRaw

View File

@ -1,7 +1,7 @@
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMemoStore, useShortcutStore } from "../store/module";
import { useShortcutStore, useTagStore } from "../store/module";
import { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import Icon from "./Icon";
@ -162,9 +162,9 @@ interface MemoFilterInputerProps {
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
const { index, filter, handleFilterChange, handleFilterRemove } = props;
const { t } = useTranslation();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const [value, setValue] = useState<string>(filter.value.value);
const tags = Array.from(memoStore.getState().tags);
const tags = Array.from(tagStore.getState().tags);
const { type } = filter;
const typeDataSource = Object.values(filterConsts).map(({ text, value }) => ({ text: t(text), value }));

View File

@ -0,0 +1,140 @@
import { TextField } from "@mui/joy";
import { useEffect, useState } from "react";
import { useTagStore } from "../store/module";
import { getTagSuggestionList } from "../helpers/api";
import Tag from "../labs/marked/parser/Tag";
import Icon from "./Icon";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
type Props = DialogProps;
const validateTagName = (tagName: string): boolean => {
const matchResult = Tag.matcher(`#${tagName}`);
if (!matchResult || matchResult[1] !== tagName) {
return false;
}
return true;
};
const CreateTagDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const tagStore = useTagStore();
const [tagName, setTagName] = useState<string>("");
const [suggestTagNameList, setSuggestTagNameList] = useState<string[]>([]);
const tagNameList = tagStore.state.tags;
useEffect(() => {
getTagSuggestionList().then(({ data }) => {
setSuggestTagNameList(data.data.filter((tag) => !tagNameList.includes(tag) && validateTagName(tag)));
});
}, [tagNameList]);
const handleTagNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const tagName = e.target.value as string;
setTagName(tagName.trim());
};
const handleRemoveSuggestTag = (tag: string) => {
setSuggestTagNameList(suggestTagNameList.filter((item) => item !== tag));
};
const handleSaveBtnClick = async () => {
if (!validateTagName(tagName)) {
toastHelper.error("Invalid tag name");
return;
}
try {
await tagStore.upsertTag(tagName);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};
const handleDeleteTag = async (tag: string) => {
await tagStore.deleteTag(tag);
};
const handleSaveSuggestTagList = async () => {
for (const tagName of suggestTagNameList) {
if (validateTagName(tagName)) {
await tagStore.upsertTag(tagName);
}
}
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">Create Tag</p>
<button className="btn close-btn" onClick={() => destroy()}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-80">
<TextField
className="mb-2"
placeholder="TAG_NAME"
value={tagName}
onChange={handleTagNameChanged}
fullWidth
startDecorator={<Icon.Hash className="w-4 h-auto" />}
endDecorator={<Icon.CheckCircle onClick={handleSaveBtnClick} className="w-4 h-auto" />}
/>
{tagNameList.length > 0 && (
<>
<p className="w-full mt-2 mb-1 text-sm text-gray-400">All tags</p>
<div className="w-full flex flex-row justify-start items-start flex-wrap">
{tagNameList.map((tag) => (
<span
className="text-sm mr-2 mt-1 font-mono cursor-pointer truncate hover:opacity-60 hover:line-through"
key={tag}
onClick={() => handleDeleteTag(tag)}
>
#{tag}
</span>
))}
</div>
</>
)}
{suggestTagNameList.length > 0 && (
<>
<p className="w-full mt-2 mb-1 text-sm text-gray-400">Tag suggestions</p>
<div className="w-full flex flex-row justify-start items-start flex-wrap">
{suggestTagNameList.map((tag) => (
<span
className="text-sm mr-2 mt-1 font-mono cursor-pointer truncate hover:opacity-60 hover:line-through"
key={tag}
onClick={() => handleRemoveSuggestTag(tag)}
>
#{tag}
</span>
))}
</div>
<button
className="mt-2 text-sm border px-2 leading-6 rounded cursor-pointer hover:opacity-80 hover:shadow"
onClick={handleSaveSuggestTagList}
>
Save all
</button>
</>
)}
</div>
</>
);
};
function showCreateTagDialog() {
generateDialog(
{
className: "create-tag-dialog",
dialogName: "create-tag-dialog",
},
CreateTagDialog
);
}
export default showCreateTagDialog;

View File

@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next";
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
import { useEditorStore, useLocationStore, useMemoStore, useResourceStore, useUserStore } from "../store/module";
import { useEditorStore, useLocationStore, useMemoStore, useResourceStore, useTagStore, useUserStore } from "../store/module";
import * as storage from "../helpers/storage";
import Icon from "./Icon";
import toastHelper from "./Toast";
@ -44,6 +44,7 @@ const MemoEditor = () => {
const editorStore = useEditorStore();
const locationStore = useLocationStore();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const resourceStore = useResourceStore();
const [state, setState] = useState<State>({
@ -57,7 +58,7 @@ const MemoEditor = () => {
const tagSelectorRef = useRef<HTMLDivElement>(null);
const user = userStore.state.user as User;
const setting = user.setting;
const tags = memoStore.state.tags;
const tags = tagStore.state.tags;
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocationStore, useMemoStore, useUserStore } from "../store/module";
import { useLocationStore, useTagStore, useUserStore } from "../store/module";
import useToggle from "../hooks/useToggle";
import Icon from "./Icon";
import showCreateTagDialog from "./CreateTagDialog";
import "../less/tag-list.less";
interface Tag {
@ -15,16 +16,14 @@ const TagList = () => {
const { t } = useTranslation();
const locationStore = useLocationStore();
const userStore = useUserStore();
const memoStore = useMemoStore();
const { memos, tags: tagsText } = memoStore.state;
const tagStore = useTagStore();
const tagsText = tagStore.state.tags;
const query = locationStore.state.query;
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
if (memos.length > 0) {
memoStore.updateTagsState();
}
}, [memos]);
tagStore.fetchTags();
}, []);
useEffect(() => {
const sortedTags = Array.from(tagsText).sort();
@ -72,7 +71,15 @@ const TagList = () => {
return (
<div className="tags-wrapper">
<p className="title-text">{t("common.tags")}</p>
<div className="w-full flex flex-row justify-start items-center px-4 mb-1">
<span className="text-sm leading-6 font-mono text-gray-400">{t("common.tags")}</span>
<button
onClick={() => showCreateTagDialog()}
className="flex flex-col justify-center items-center w-5 h-5 bg-gray-200 dark:bg-zinc-700 rounded ml-2 hover:shadow"
>
<Icon.Plus className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="tags-container">
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLocationStore, useMemoStore, useUserStore } from "../store/module";
import { useLocationStore, useMemoStore, useTagStore, useUserStore } from "../store/module";
import { getMemoStats } from "../helpers/api";
import * as utils from "../helpers/utils";
import Icon from "./Icon";
@ -17,8 +17,10 @@ const UserBanner = () => {
const locationStore = useLocationStore();
const userStore = useUserStore();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const { user, owner } = userStore.state;
const { memos, tags } = memoStore.state;
const { memos } = memoStore.state;
const tags = tagStore.state.tags;
const [username, setUsername] = useState("Memos");
const [memoAmount, setMemoAmount] = useState(0);
const [createdDays, setCreatedDays] = useState(0);

View File

@ -187,6 +187,20 @@ export function getTagList(tagFind?: TagFind) {
return axios.get<ResponseObject<string[]>>(`/api/tag?${queryList.join("&")}`);
}
export function getTagSuggestionList() {
return axios.get<ResponseObject<string[]>>(`/api/tag/suggestion`);
}
export function upsertTag(tagName: string) {
return axios.post<ResponseObject<string>>(`/api/tag`, {
name: tagName,
});
}
export function deleteTag(tagName: string) {
return axios.delete<ResponseObject<string>>(`/api/tag/${tagName}`);
}
export async function getRepoStarCount() {
const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, {
headers: {

View File

@ -1,10 +1,6 @@
.tags-wrapper {
@apply flex flex-col justify-start items-start px-2 w-full h-auto flex-nowrap pb-4 mt-2 grow hide-scrollbar;
> .title-text {
@apply w-full px-4 text-sm leading-6 font-mono text-gray-400;
}
> .tags-container {
@apply flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mb-2 mt-1;

View File

@ -8,12 +8,14 @@ import shortcutReducer from "./reducer/shortcut";
import locationReducer from "./reducer/location";
import resourceReducer from "./reducer/resource";
import dialogReducer from "./reducer/dialog";
import tagReducer from "./reducer/tag";
const store = configureStore({
reducer: {
global: globalReducer,
user: userReducer,
memo: memoReducer,
tag: tagReducer,
editor: editorReducer,
shortcut: shortcutReducer,
location: locationReducer,

View File

@ -2,6 +2,7 @@ export * from "./editor";
export * from "./global";
export * from "./location";
export * from "./memo";
export * from "./tag";
export * from "./resource";
export * from "./shortcut";
export * from "./user";

View File

@ -3,7 +3,7 @@ import * as api from "../../helpers/api";
import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts";
import { useUserStore } from "./";
import store, { useAppSelector } from "../";
import { createMemo, deleteMemo, patchMemo, setIsFetching, setMemos, setTags } from "../reducer/memo";
import { createMemo, deleteMemo, patchMemo, setIsFetching, setMemos } from "../reducer/memo";
const convertResponseModelMemo = (memo: Memo): Memo => {
return {
@ -85,14 +85,6 @@ export const useMemoStore = () => {
return await fetchMemoById(memoId);
},
updateTagsState: async () => {
const tagFind: TagFind = {};
if (userStore.isVisitorMode()) {
tagFind.creatorId = userStore.getUserIdFromPath();
}
const { data } = (await api.getTagList(tagFind)).data;
store.dispatch(setTags(data));
},
getLinkedMemos: async (memoId: MemoId): Promise<Memo[]> => {
const regex = new RegExp(`[@(.+?)](${memoId})`);
return state.memos.filter((m) => m.content.match(regex));

View File

@ -0,0 +1,31 @@
import store, { useAppSelector } from "..";
import * as api from "../../helpers/api";
import { deleteTag, setTags, upsertTag } from "../reducer/tag";
import { useUserStore } from "./";
export const useTagStore = () => {
const state = useAppSelector((state) => state.tag);
const userStore = useUserStore();
return {
state,
getState: () => {
return store.getState().tag;
},
fetchTags: async () => {
const tagFind: TagFind = {};
if (userStore.isVisitorMode()) {
tagFind.creatorId = userStore.getUserIdFromPath();
}
const { data } = (await api.getTagList(tagFind)).data;
store.dispatch(setTags(data));
},
upsertTag: async (tagName: string) => {
await api.upsertTag(tagName);
store.dispatch(upsertTag(tagName));
},
deleteTag: async (tagName: string) => {
await api.deleteTag(tagName);
store.dispatch(deleteTag(tagName));
},
};
};

View File

@ -2,7 +2,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
memos: Memo[];
tags: string[];
isFetching: boolean;
}
@ -10,7 +9,6 @@ const memoSlice = createSlice({
name: "memo",
initialState: {
memos: [],
tags: [],
// isFetching flag should starts with true.
isFetching: true,
} as State,
@ -52,12 +50,6 @@ const memoSlice = createSlice({
}),
};
},
setTags: (state, action: PayloadAction<string[]>) => {
return {
...state,
tags: action.payload,
};
},
setIsFetching: (state, action: PayloadAction<boolean>) => {
return {
...state,
@ -67,6 +59,6 @@ const memoSlice = createSlice({
},
});
export const { setMemos, createMemo, patchMemo, deleteMemo, setTags, setIsFetching } = memoSlice.actions;
export const { setMemos, createMemo, patchMemo, deleteMemo, setIsFetching } = memoSlice.actions;
export default memoSlice.reducer;

View File

@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
tags: string[];
}
const tagSlice = createSlice({
name: "tag",
initialState: {
tags: [],
} as State,
reducers: {
setTags: (state, action: PayloadAction<string[]>) => {
return {
...state,
tags: action.payload,
};
},
upsertTag: (state, action: PayloadAction<string>) => {
if (state.tags.includes(action.payload)) {
return state;
}
return {
...state,
tags: state.tags.concat(action.payload),
};
},
deleteTag: (state, action: PayloadAction<string>) => {
return {
...state,
tags: state.tags.filter((tag) => {
return tag !== action.payload;
}),
};
},
},
});
export const { setTags, upsertTag, deleteTag } = tagSlice.actions;
export default tagSlice.reducer;