Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: patch resource filename #360

Merged
merged 5 commits into from
Oct 29, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,12 @@ type ResourceDelete struct {
// Standard fields
CreatorID int
}

type ResourcePatch struct {
ID int

// Standard fields
UpdatedTs *int64

Filename *string `json:"filename"`
}
42 changes: 42 additions & 0 deletions server/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"net/http"
"strconv"
"time"

"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
Expand Down Expand Up @@ -178,6 +179,47 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {

return c.JSON(http.StatusOK, true)
})

g.PATCH("/resource/:resourceId", 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")
}

resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}

resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
}
if _, err := s.Store.FindResource(ctx, resourceFind); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
}

currentTs := time.Now().Unix()
resourcePatch := &api.ResourcePatch{
ID: resourceID,
UpdatedTs: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(resourcePatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
}

resource, err := s.Store.PatchResource(ctx, resourcePatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
}

c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
}

func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
Expand Down
60 changes: 60 additions & 0 deletions store/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete)
return nil
}

func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()

resourceRaw, err := patchResource(ctx, tx, patch)
if err != nil {
return nil, err
}

if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}

if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
return nil, err
}

resource := resourceRaw.toResource()

return resource, nil
}

func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
query := `
INSERT INTO resource (
Expand Down Expand Up @@ -217,6 +242,41 @@ func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate)
return &resourceRaw, nil
}

func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
set, args := []string{}, []interface{}{}

if v := patch.UpdatedTs; v != nil {
set, args = append(set, "updated_ts = ?"), append(args, *v)
}
if v := patch.Filename; v != nil {
set, args = append(set, "filename = ?"), append(args, *v)
}

args = append(args, patch.ID)

query := `
UPDATE resource
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
`
var resourceRaw resourceRaw
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}

return &resourceRaw, nil
}

func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}

Expand Down
90 changes: 90 additions & 0 deletions web/src/components/ChangeResourceFilenameDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { resourceService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-resource-filename-dialog.less";

interface Props extends DialogProps {
resourceId: ResourceId;
resourceFilename: string;
}

const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { destroy, resourceId, resourceFilename } = props;
const [filename, setFilename] = useState<string>("");

useEffect(() => {
setFilename(resourceFilename);
}, []);
Zeng1998 marked this conversation as resolved.
Show resolved Hide resolved

const handleFilenameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextUsername = e.target.value as string;
setFilename(nextUsername);
};

const handleCloseBtnClick = () => {
destroy();
};

const handleSaveBtnClick = async () => {
if (filename === resourceFilename) {
handleCloseBtnClick();
return;
}
// TODO Maybe some validation about filenames is needed here? I'm not sure
Zeng1998 marked this conversation as resolved.
Show resolved Hide resolved
try {
await resourceService.patchResource({
id: resourceId,
filename: filename,
});
// TODO
Zeng1998 marked this conversation as resolved.
Show resolved Hide resolved
toastHelper.info(t("message.resource-filename-updated"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};

return (
<>
<div className="dialog-header-container">
<p className="title-text">{t("message.change-resource-filename")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<label className="form-label input-form-label">
<input type="text" value={filename} onChange={handleFilenameChanged} />
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
{t("common.save")}
</span>
</div>
</div>
</>
);
};

function showChangeResourceFilenameDialog(resourceId: ResourceId, resourceFilename: string) {
generateDialog(
{
className: "change-resource-filename-dialog",
},
ChangeResourceFilenameDialog,
{
resourceId,
resourceFilename,
}
);
}

export default showChangeResourceFilenameDialog;
24 changes: 15 additions & 9 deletions web/src/components/ResourcesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@ import Icon from "./Icon";
import toastHelper from "./Toast";
import "../less/resources-dialog.less";
import * as utils from "../helpers/utils";
import showChangeResourceFilenameDialog from "./ChangeResourceFilenameDialog";
import { useAppSelector } from "../store";

type Props = DialogProps;

interface State {
resources: Resource[];
isUploadingResource: boolean;
}

const ResourcesDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const { t } = useTranslation();
const loadingState = useLoading();
const { resources } = useAppSelector((state) => state.resource);
const [state, setState] = useState<State>({
resources: [],
isUploadingResource: false,
});

useEffect(() => {
fetchResources()
.catch((error) => {
Expand All @@ -41,10 +41,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {

const fetchResources = async () => {
const data = await resourceService.getResourceList();
setState({
...state,
resources: data,
});
};

const handleUploadFileBtnClick = async () => {
Expand Down Expand Up @@ -99,6 +95,10 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
}
};

const handleRenameBtnClick = (resource: Resource) => {
showChangeResourceFilenameDialog(resource.id, resource.filename);
};

const handleCopyResourceLinkBtnClick = (resource: Resource) => {
copy(`${window.location.origin}/o/r/${resource.id}/${resource.filename}`);
toastHelper.success("Succeed to copy resource link to clipboard");
Expand Down Expand Up @@ -165,10 +165,10 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
<span className="field-text type-text">TYPE</span>
<span></span>
</div>
{state.resources.length === 0 ? (
{resources.length === 0 ? (
<p className="tip-text">{t("resources.no-resources")}</p>
) : (
state.resources.map((resource) => (
resources.map((resource) => (
<div key={resource.id} className="resource-container">
<span className="field-text id-text">{resource.id}</span>
<span className="field-text name-text">
Expand Down Expand Up @@ -198,6 +198,12 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
>
{t("resources.preview")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
onClick={() => handleRenameBtnClick(resource)}
>
{t("resources.rename")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
onClick={() => handleCopyResourceLinkBtnClick(resource)}
Expand Down
4 changes: 4 additions & 0 deletions web/src/helpers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ export function deleteResourceById(id: ResourceId) {
return axios.delete(`/api/resource/${id}`);
}

export function patchResource(resourcePatch: ResourcePatch) {
return axios.patch<ResponseObject<Resource>>(`/api/resource/${resourcePatch.id}`, resourcePatch);
}

export function getMemoResourceList(memoId: MemoId) {
return axios.get<ResponseObject<Resource[]>>(`/api/memo/${memoId}/resource`);
}
Expand Down
47 changes: 47 additions & 0 deletions web/src/less/change-resource-filename-dialog.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
@import "./mixin.less";

.change-resource-filename-dialog {
> .dialog-container {
@apply w-72;

> .dialog-content-container {
.flex(column, flex-start, flex-start);

> .tip-text {
@apply bg-gray-400 text-xs p-2 rounded-lg;
}

> .form-label {
@apply flex flex-col justify-start items-start relative w-full leading-relaxed;

&.input-form-label {
@apply py-3 pb-1;

> input {
@apply w-full p-2 text-sm leading-6 rounded border border-gray-400 bg-transparent;
}
}
}

> .btns-container {
@apply flex flex-row justify-end items-center mt-2 w-full;

> .btn {
@apply text-sm px-4 py-2 rounded ml-2 bg-gray-400;

&:hover {
@apply opacity-80;
}

&.confirm-btn {
@apply bg-green-600 text-white shadow-inner;
}

&.cancel-btn {
background-color: unset;
}
}
}
}
}
}
Loading