From 71f9d209f42bc6ac970161f2b49a1aadef7af30c Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Thu, 15 Oct 2026 20:27:13 -0300 Subject: [PATCH 1/3] docs: plan renaming folders and files (LUI-180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptance file docs/tickets/LUI-177/LUI-180/aceite.md (CA1-CA9). The OpenAPI snapshot gains PATCH /items/{id} (UpdateItemDto with only name, 200 ItemDto) and the UpdateItemDto schema; no table changes. Decision: the route is the LLD's PATCH /items/{id}, with only name in this delivery. name is required now, and LUI-181 relaxes it to name and/or parentId, a change that breaks no client. A body with no field is therefore validation_error, as the ticket asks. Decision: renaming the root folder answers validation_error, and not_found stays for what the User cannot see (missing, another User's, pending, not a UUID). The root's name is fixed, so the request is invalid input, and no new error code is needed. The root alias is accepted in the path, as in GET /items/{id}, and leads to the same refusal. Decision: the rename takes no advisory lock, because it changes neither the depth nor the shape of the tree; only the move (LUI-181) needs it. Decision: the name rules are shared with the folder creation (trimmed ends, 1 to 255 characters measured after the trim, no "/" or control characters), by extracting the name decorators the CreateFolderDto uses, so the two routes never drift apart. Decision: the download needs no change, because downloadUrl reads the name from the item row at each call, so CA5 is proved by a test only. Decision: the row menu moves to a client component that owns the rename dialog, because the Menu unmounts its items when it closes and would take the dialog with it. The folder row gains the "Mais ações" button, with "Renomear" only; "Mover para…" comes with LUI-181 and "Excluir" with the trash. Files and interfaces the implementation will touch: - apps/api/src/modules/items: ItemsController.update (PATCH :id), ItemsService.rename and ItemsRepository.rename (reads the active item of the owner, refuses the root, updates the name); dto/update-item.dto.ts; the name decorators extracted from dto/create-folder.dto.ts. - apps/api/openapi.json: regenerated from the controller and the DTO; it must match the snapshot of this plan. - apps/api/test: items-rename.e2e-spec.ts and the rename call in support/items.ts. - apps/web: updateItem in lib/api/items.ts and its input type in lib/api/types.ts, renameItem in lib/dal/items.ts, the renameItem Server Action in app/(drive)/actions.ts, components/files/row-actions.tsx (menu and dialog) and rename-dialog.tsx, used by file-row.tsx. - apps/web/e2e: rename.spec.ts. - docs/lld.md: section 3.3 (the PATCH route with only the name for now) and the web section that says the folder row has no "Mais ações" button. Existing tests whose behavior changes: none. Data changes: none. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/openapi.json | 122 +++++++++++++++++++++++++ docs/tickets/LUI-177/LUI-180/aceite.md | 83 +++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 docs/tickets/LUI-177/LUI-180/aceite.md diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ca59e22..6bc2e42 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -664,6 +664,115 @@ "tags": [ "items" ] + }, + "patch": { + "operationId": "ItemsController_update", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateItemDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemDto" + } + } + } + }, + "400": { + "description": "validation_error", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "validation_error" + ] + } + } + } + } + } + }, + "401": { + "description": "unauthenticated", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + } + } + } + } + } + }, + "404": { + "description": "not_found", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + } + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "items" + ] } }, "/v1/items/{id}/children": { @@ -1650,6 +1759,19 @@ "trail" ] }, + "UpdateItemDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "name" + ] + }, "DownloadUrlDto": { "type": "object", "properties": { diff --git a/docs/tickets/LUI-177/LUI-180/aceite.md b/docs/tickets/LUI-177/LUI-180/aceite.md new file mode 100644 index 0000000..f20a1bb --- /dev/null +++ b/docs/tickets/LUI-177/LUI-180/aceite.md @@ -0,0 +1,83 @@ +# Aceite do LUI-180 + +Ticket de renomear pastas e arquivos. Os CA1 a CA5 são provados por testes HTTP da API (um arquivo de ponta a ponta, com o PostgreSQL de testes real), e os CA6 a CA9, por testes no Playwright. + +Os testes HTTP preparam pastas, arquivos e Itens `pending` direto no banco (`itemRoutes(app).seed`), como os do LUI-179: a rota de renomear é a única que eles exercitam. O CA5 é a exceção, porque precisa de um objeto no storage: envia o arquivo pelo caminho real, como os testes de `items-download-url.e2e-spec.ts`, e lê o `Content-Disposition` da URL assinada no emulador. + +A Pasta raiz não se renomeia, e a recusa é um `validation_error` (400): o nome dela é fixo, então o pedido é entrada inválida. O `not_found` fica para o que o Usuário não enxerga (inexistente, de outro Usuário, `pending` ou id que não é UUID), como no resto das rotas de Itens. O atalho `root` vale na rota, como em `GET /items/{id}`, e leva à mesma recusa. + +A rota é `PATCH /items/{id}`, a do LLD, com só o `name` nesta entrega: o `parentId` entra no LUI-181, que torna o `name` opcional. O corpo sem nenhum campo é, por isso, um corpo sem o `name` obrigatório. + +Os testes no navegador seguem o estilo de `folders.spec.ts`: um Usuário verificado, a árvore preparada no banco por `e2e/support/items.ts` e as telas lidas pelos papéis e nomes que o Usuário vê. A data de modificação é conferida preparando o item com uma data antiga (`updatedAt`). A aparência do diálogo e do menu é a conferência com o Figma do LUI-178, da validação visual do `docs/design-system.md`, e não deste arquivo. + +## LUI-180-CA1 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com uma pasta e um arquivo, ambos com a data de modificação antiga, quando ele pede `PATCH /items/{id}` de cada um com o nome " Novo nome ", então cada resposta é 200 com o Item de nome "Novo nome", sem os espaços das pontas, e de `updatedAt` posterior ao anterior, e a lista da pasta de origem mostra o nome novo. Dado um irmão que já se chama "Novo nome", o renomear é aceito, e os dois ficam na lista. + +**Teste previsto:** testes de ponta a ponta da API, `items-rename.e2e-spec.ts`, "renomeia a pasta e o arquivo, sem os espaços das pontas e com a data atualizada" e "aceita um nome igual ao de um irmão". + +## LUI-180-CA2 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com um arquivo, quando ele pede `PATCH /items/{id}` com o corpo sem nenhum campo, com o nome vazio, só de espaços, com `/` ou com 256 caracteres, então cada pedido responde `validation_error`, e o nome do arquivo continua o mesmo. Um nome de 255 caracteres, e outro cujo comprimento só passa de 255 por causa dos espaços das pontas, são aceitos. + +**Teste previsto:** testes de ponta a ponta da API, `items-rename.e2e-spec.ts`, "recusa o nome inválido com validation_error" e "mede os 255 caracteres depois de descartar os espaços das pontas". + +## LUI-180-CA3 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com a Pasta raiz, quando ele pede `PATCH /items/{id da raiz}` e `PATCH /items/root` com um nome válido, então cada pedido responde `validation_error`, e a Pasta raiz continua com o nome "Meus arquivos" (lido no banco e no `GET /items/root`, pela trilha). + +**Teste previsto:** testes de ponta a ponta da API, `items-rename.e2e-spec.ts`, "recusa renomear a Pasta raiz, pelo id e pelo atalho root". + +## LUI-180-CA4 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário, quando ele renomeia um Item de outro Usuário, um id inexistente, um texto que não é UUID e um Item `pending`, então cada pedido responde `not_found`, e os nomes não mudam. Sem o token de acesso, o pedido responde `unauthenticated`. + +**Teste previsto:** testes de ponta a ponta da API, `items-rename.e2e-spec.ts`, "responde not_found ao Item que não é ativo e do Usuário" e "responde unauthenticated sem o token". + +## LUI-180-CA5 + +**Marca:** teste HTTP + +**Cenário:** Dado um arquivo enviado pelo caminho real e depois renomeado para "novo.txt", quando o Usuário pede `GET /items/{id}/download-url` e abre a URL assinada no emulador, então os bytes são os do arquivo e o `Content-Disposition` traz o nome "novo.txt", e não o original. + +**Teste previsto:** testes de ponta a ponta da API, `items-rename.e2e-spec.ts`, "entrega o download com o nome novo". + +## LUI-180-CA6 + +**Marca:** teste no Playwright + +**Cenário:** Dado um Usuário com a pasta "Fotos" e o arquivo "relatorio.txt", este com a data de modificação antiga, quando ele abre "Mais ações" da linha, escolhe "Renomear", vê o campo com o nome atual todo selecionado (a extensão inclusive), digita outro nome e confirma, então o diálogo fecha, um aviso confirma, a lista mostra o nome novo (no arquivo, também a data de modificação nova) e o nome antigo some. O mesmo vale para a pasta, que passa a ter o botão "Mais ações". + +**Teste previsto:** testes no Playwright, `rename.spec.ts`, "o Usuário renomeia um arquivo pelo diálogo, que abre com o nome selecionado" e "o Usuário renomeia uma pasta pelo menu da linha". + +## LUI-180-CA7 + +**Marca:** teste no Playwright + +**Cenário:** Dado o diálogo de renomear aberto, quando a pessoa confirma com o campo vazio e depois com um nome que tem `/`, então o diálogo continua aberto e mostra o erro em português abaixo do campo, e o nome na lista não muda. + +**Teste previsto:** testes no Playwright, `rename.spec.ts`, "o diálogo mostra o erro do nome inválido, sem fechar". + +## LUI-180-CA8 + +**Marca:** teste no Playwright + +**Cenário:** Dada uma pasta e um arquivo na lista, quando a pessoa abre o menu "Mais ações" de cada linha, então o da pasta tem "Renomear" e o do arquivo tem "Baixar" e "Renomear", e nenhum dos dois tem "Excluir". + +**Teste previsto:** testes no Playwright, `rename.spec.ts`, "os menus da pasta e do arquivo não têm Excluir". + +## LUI-180-CA9 + +**Marca:** teste no Playwright + +**Cenário:** Dado o foco no botão "Mais ações" de um arquivo, quando a pessoa o abre com Enter, escolhe "Renomear" pelas setas e Enter, digita o nome novo e confirma com Enter, então o diálogo fecha e a lista mostra o nome novo; e, com o diálogo aberto de novo, Esc o fecha sem renomear. + +**Teste previsto:** testes no Playwright, `rename.spec.ts`, "o diálogo de renomear abre, confirma e fecha pelo teclado". From e9b76fd1df8e463153b69bbdd00d04c8d70227dd Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Thu, 15 Oct 2026 20:54:39 -0300 Subject: [PATCH 2/3] feat: rename folders and files (LUI-180) PATCH /items/{id} changes the name of an active item of the User. The root folder is refused with validation_error and anything the User cannot see is not_found. The row menu gains "Renomear" on files and folders, with a dialog that opens with the current name fully selected. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/openapi.json | 42 +-- .../src/modules/items/dto/update-item.dto.ts | 8 + .../src/modules/items/entities/item.entity.ts | 6 + .../api/src/modules/items/items.controller.ts | 25 +- .../api/src/modules/items/items.repository.ts | 28 ++ apps/api/src/modules/items/items.service.ts | 25 ++ apps/api/test/items-rename.e2e-spec.ts | 256 ++++++++++++++++++ apps/api/test/support/items.ts | 22 +- apps/web/app/(drive)/actions.ts | 55 +++- apps/web/components/files/file-row.tsx | 31 +-- apps/web/components/files/openable-row.tsx | 6 +- apps/web/components/files/rename-dialog.tsx | 69 +++++ apps/web/components/files/row-actions.tsx | 53 ++++ apps/web/components/ui/menu.tsx | 22 +- apps/web/e2e/rename.spec.ts | 157 +++++++++++ apps/web/lib/api/items.ts | 14 + apps/web/lib/api/types.ts | 5 + apps/web/lib/dal/items.ts | 10 + docs/lld.md | 4 +- 19 files changed, 779 insertions(+), 59 deletions(-) create mode 100644 apps/api/src/modules/items/dto/update-item.dto.ts create mode 100644 apps/api/test/items-rename.e2e-spec.ts create mode 100644 apps/web/components/files/rename-dialog.tsx create mode 100644 apps/web/components/files/row-actions.tsx create mode 100644 apps/web/e2e/rename.spec.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 6bc2e42..79c2546 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -811,10 +811,10 @@ "required": false, "in": "query", "schema": { + "type": "string", "enum": [ "folder" - ], - "type": "string" + ] } } ], @@ -1707,25 +1707,6 @@ "updatedAt" ] }, - "ItemListDto": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ItemDto" - } - }, - "nextCursor": { - "type": "string", - "nullable": true - } - }, - "required": [ - "items", - "nextCursor" - ] - }, "ItemTrailEntryDto": { "type": "object", "properties": { @@ -1772,6 +1753,25 @@ "name" ] }, + "ItemListDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemDto" + } + }, + "nextCursor": { + "type": "string", + "nullable": true + } + }, + "required": [ + "items", + "nextCursor" + ] + }, "DownloadUrlDto": { "type": "object", "properties": { diff --git a/apps/api/src/modules/items/dto/update-item.dto.ts b/apps/api/src/modules/items/dto/update-item.dto.ts new file mode 100644 index 0000000..91b0240 --- /dev/null +++ b/apps/api/src/modules/items/dto/update-item.dto.ts @@ -0,0 +1,8 @@ +import { PickType } from '@nestjs/swagger'; +import { CreateFolderDto } from './create-folder.dto.js'; + +/** + * A atualização de um Item (seção 3.3 do docs/lld.md). Só o nome por ora: as + * regras dele são as do nome de uma pasta nova. + */ +export class UpdateItemDto extends PickType(CreateFolderDto, ['name']) {} diff --git a/apps/api/src/modules/items/entities/item.entity.ts b/apps/api/src/modules/items/entities/item.entity.ts index f981600..75cea28 100644 --- a/apps/api/src/modules/items/entities/item.entity.ts +++ b/apps/api/src/modules/items/entities/item.entity.ts @@ -58,6 +58,12 @@ export type CreateFolderResult = | { status: 'parent_not_found' } | { status: 'too_deep' }; +/** O que renomear um Item resolve: renomeado, a Pasta raiz (que não se renomeia) ou sem Item ativo do Usuário. */ +export type RenameResult = + | { status: 'renamed'; item: Item } + | { status: 'root' } + | { status: 'not_found' }; + /** Quantas pastas o caminho de um Item pode ter, sem contar a Pasta raiz. */ export const MAX_FOLDER_DEPTH = 50; diff --git a/apps/api/src/modules/items/items.controller.ts b/apps/api/src/modules/items/items.controller.ts index c782e89..b0936b5 100644 --- a/apps/api/src/modules/items/items.controller.ts +++ b/apps/api/src/modules/items/items.controller.ts @@ -1,15 +1,25 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from '../../common/auth/auth.guard.js'; import { CurrentUserId } from '../../common/auth/current-user-id.decorator.js'; import { ApiProblem } from '../../common/openapi/api-problem.decorator.js'; import { DownloadUrlDto } from './dto/download-url.dto.js'; import { ItemDetailDto } from './dto/item-detail.dto.js'; +import { ItemDto } from './dto/item.dto.js'; import { ItemListDto } from './dto/item-list.dto.js'; import { DEFAULT_PAGE_SIZE, ListChildrenQueryDto, } from './dto/list-children-query.dto.js'; +import { UpdateItemDto } from './dto/update-item.dto.js'; import { ItemsService } from './items.service.js'; @ApiTags('items') @@ -30,6 +40,19 @@ export class ItemsController { return this.items.getFolder(userId, id); } + @Patch(':id') + @ApiOkResponse({ type: ItemDto }) + @ApiProblem(400, 'validation_error') + @ApiProblem(401, 'unauthenticated') + @ApiProblem(404, 'not_found') + update( + @Param('id') id: string, + @Body() body: UpdateItemDto, + @CurrentUserId() userId: string, + ): Promise { + return this.items.rename(userId, id, body.name); + } + @Get(':id/children') @ApiOkResponse({ type: ItemListDto }) @ApiProblem(400, 'validation_error') diff --git a/apps/api/src/modules/items/items.repository.ts b/apps/api/src/modules/items/items.repository.ts index f4229d7..2f59142 100644 --- a/apps/api/src/modules/items/items.repository.ts +++ b/apps/api/src/modules/items/items.repository.ts @@ -13,6 +13,7 @@ import { ItemPosition, ItemType, NewPendingFile, + RenameResult, ROOT_FOLDER_NAME, } from './entities/item.entity.js'; @@ -183,6 +184,33 @@ export class ItemsRepository { }); } + /** + * Troca o nome do Item ativo do Usuário e renova o `updatedAt`. A Pasta + * raiz, a única sem pai, fica de fora e é dita à parte. + */ + async renameActive( + ownerId: string, + id: string, + name: string, + ): Promise { + const found = await this.prisma.item.findFirst({ + where: { id, ownerId, status: 'active' }, + select: { parentId: true }, + }); + if (!found) { + return { status: 'not_found' }; + } + if (found.parentId === null) { + return { status: 'root' }; + } + const row = await this.prisma.item.update({ + where: { id }, + data: { name }, + select: ITEM_FIELDS, + }); + return { status: 'renamed', item: toItem(row) }; + } + /** Cria o Item `pending` de um arquivo, invisível nas listagens. */ async createPendingFile( file: NewPendingFile, diff --git a/apps/api/src/modules/items/items.service.ts b/apps/api/src/modules/items/items.service.ts index a4e31d5..08c33aa 100644 --- a/apps/api/src/modules/items/items.service.ts +++ b/apps/api/src/modules/items/items.service.ts @@ -16,6 +16,7 @@ import { ItemPage, NewPendingFile, } from './entities/item.entity.js'; +import { InputValidationError } from '../../common/errors/input-validation.error.js'; import { ItemNotFoundError } from './errors/item-not-found.error.js'; import { MaxDepthExceededError } from './errors/max-depth-exceeded.error.js'; import { decodeItemCursor, encodeItemCursor } from './item-cursor.js'; @@ -118,6 +119,30 @@ export class ItemsService { } } + /** + * Troca o nome de um Item ativo do Usuário, que passa a ter a data de + * modificação de agora. A Pasta raiz, pelo id ou pelo atalho `root`, não se + * renomeia: é entrada inválida. Qualquer outro Item que o Usuário não vê + * (inexistente, de outro dono, `pending`, id que não é UUID) é `not_found`. + */ + async rename(ownerId: string, id: string, name: string): Promise { + const result = await this.items.renameActive( + ownerId, + await this.folderIdOf(ownerId, id), + name, + ); + switch (result.status) { + case 'renamed': + return result.item; + case 'root': + throw new InputValidationError([ + { field: 'id', messages: ['A Pasta raiz não pode ser renomeada.'] }, + ]); + case 'not_found': + throw new ItemNotFoundError(); + } + } + /** * O id da pasta pedida: o da Pasta raiz, pelo atalho `root`, ou o próprio id. * Um texto que não é UUID é `not_found`. Se a pasta existe, quem lê confere. diff --git a/apps/api/test/items-rename.e2e-spec.ts b/apps/api/test/items-rename.e2e-spec.ts new file mode 100644 index 0000000..28ddd2a --- /dev/null +++ b/apps/api/test/items-rename.e2e-spec.ts @@ -0,0 +1,256 @@ +import { INestApplication } from '@nestjs/common'; +import { App } from 'supertest/types.js'; +import { v7 as uuidv7 } from 'uuid'; +import { storageConfig } from '../src/config/storage.config.js'; +import { authRoutes } from './support/auth-routes.js'; +import { createTestApp } from './support/create-test-app.js'; +import { itemRoutes } from './support/items.js'; +import { uniqueEmail } from './support/mailpit.js'; +import { + expectProblem, + expectValidationError, + NOT_FOUND, +} from './support/problem.js'; +import { getSignedUrlOnEmulator } from './support/storage-emulator.js'; +import { uploadRoutes } from './support/uploads.js'; + +interface ItemBody { + id: string; + type: string; + name: string; + updatedAt: string; +} + +interface ListBody { + items: ItemBody[]; +} + +describe('Renomear Itens', () => { + let app: INestApplication; + let auth: ReturnType; + let items: ReturnType; + let uploads: ReturnType; + + beforeAll(async () => { + app = await createTestApp(); + auth = authRoutes(app); + items = itemRoutes(app); + uploads = uploadRoutes(app); + }); + + afterAll(async () => { + await app.close(); + }); + + /** Um Usuário verificado, com Sessão, e o id dele e o da Pasta raiz. */ + const signedInUser = async () => { + const { accessToken } = await auth.openSession(uniqueEmail()); + const { body } = await auth.me(accessToken).expect(200); + const userId = (body as { id: string }).id; + return { + accessToken, + userId, + rootId: await items.rootId(accessToken, userId), + }; + }; + + describe('PATCH /items/{id}', () => { + it( + 'renomeia a pasta e o arquivo, sem os espaços das pontas e com a data atualizada', + { tags: ['@LUI-180-CA1'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const longAgo = new Date('2020-01-01T00:00:00Z'); + const [folderId, fileId] = await items.seed(userId, rootId, [ + { name: 'Fotos', type: 'folder', updatedAt: longAgo }, + { name: 'relatorio.txt', updatedAt: longAgo }, + ]); + + for (const id of [folderId, fileId]) { + const response = await items + .rename(id, accessToken, { name: ' Novo nome ' }) + .expect(200); + + const body = response.body as ItemBody; + expect(body).toMatchObject({ id, name: 'Novo nome' }); + expect(new Date(body.updatedAt).getTime()).toBeGreaterThan( + longAgo.getTime(), + ); + } + const list = await items.listChildren('root', accessToken).expect(200); + expect((list.body as ListBody).items.map((i) => i.name)).toEqual([ + 'Novo nome', + 'Novo nome', + ]); + }, + ); + + it( + 'aceita um nome igual ao de um irmão', + { tags: ['@LUI-180-CA1'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const [, fileId] = await items.seed(userId, rootId, [ + { name: 'Novo nome', type: 'folder' }, + { name: 'relatorio.txt' }, + ]); + + await items + .rename(fileId, accessToken, { name: 'Novo nome' }) + .expect(200); + + const list = await items.listChildren('root', accessToken).expect(200); + expect((list.body as ListBody).items.map((i) => i.name)).toEqual([ + 'Novo nome', + 'Novo nome', + ]); + }, + ); + + it( + 'recusa o nome inválido com validation_error', + { tags: ['@LUI-180-CA2'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const [fileId] = await items.seed(userId, rootId, [ + { name: 'relatorio.txt' }, + ]); + const inputs: object[] = [ + {}, + { name: '' }, + { name: ' ' }, + { name: 'a/b' }, + { name: 'a'.repeat(256) }, + ]; + + for (const input of inputs) { + const response = await items.rename(fileId, accessToken, input); + expectValidationError(response, 'name'); + } + const list = await items.listChildren('root', accessToken).expect(200); + expect((list.body as ListBody).items.map((i) => i.name)).toEqual([ + 'relatorio.txt', + ]); + }, + ); + + it( + 'mede os 255 caracteres depois de descartar os espaços das pontas', + { tags: ['@LUI-180-CA2'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const [fileId] = await items.seed(userId, rootId, [ + { name: 'relatorio.txt' }, + ]); + const name = 'a'.repeat(255); + + const exact = await items.rename(fileId, accessToken, { name }); + const padded = await items.rename(fileId, accessToken, { + name: ` ${name} `, + }); + + expect(exact.status).toBe(200); + expect(padded.status).toBe(200); + expect((padded.body as ItemBody).name).toBe(name); + }, + ); + + it( + 'recusa renomear a Pasta raiz, pelo id e pelo atalho root', + { tags: ['@LUI-180-CA3'] }, + async () => { + const { accessToken, rootId } = await signedInUser(); + + for (const id of [rootId, 'root']) { + const response = await items.rename(id, accessToken, { + name: 'Outro nome', + }); + expectValidationError(response, 'id'); + } + + const detail = await items.get('root', accessToken).expect(200); + expect( + (detail.body as { trail: { name: string }[] }).trail.map( + (entry) => entry.name, + ), + ).toEqual(['Meus arquivos']); + }, + ); + + it( + 'responde not_found ao Item que não é ativo e do Usuário', + { tags: ['@LUI-180-CA4'] }, + async () => { + const owner = await signedInUser(); + const other = await signedInUser(); + const [othersFile] = await items.seed(other.userId, other.rootId, [ + { name: 'de-outro.txt' }, + ]); + const [pending] = await items.seed(owner.userId, owner.rootId, [ + { name: 'pendente.txt', status: 'pending' }, + ]); + + for (const id of [othersFile, uuidv7(), 'nao-e-uuid', pending]) { + const response = await items.rename(id, owner.accessToken, { + name: 'Novo nome', + }); + expectProblem(response, NOT_FOUND); + } + + const othersList = await items.listChildren('root', other.accessToken); + expect((othersList.body as ListBody).items.map((i) => i.name)).toEqual([ + 'de-outro.txt', + ]); + }, + ); + + it( + 'responde unauthenticated sem o token', + { tags: ['@LUI-180-CA4'] }, + async () => { + const { userId, rootId } = await signedInUser(); + const [fileId] = await items.seed(userId, rootId, [ + { name: 'relatorio.txt' }, + ]); + + const response = await items.rename(fileId, undefined, { + name: 'Novo nome', + }); + + expect(response.status).toBe(401); + expect((response.body as { code: string }).code).toBe( + 'unauthenticated', + ); + }, + ); + + it( + 'entrega o download com o nome novo', + { tags: ['@LUI-180-CA5'] }, + async () => { + const { accessToken } = await signedInUser(); + const { itemId, uploadUrl } = await uploads.start(accessToken, { + name: 'original.txt', + sizeBytes: 5, + }); + await uploads.putBytes(uploadUrl, 'ola!!'); + await uploads.complete(accessToken, itemId).expect(200); + await items + .rename(itemId, accessToken, { name: 'novo.txt' }) + .expect(200); + + const response = await items.downloadUrl(itemId, accessToken); + const { url } = response.body as { url: string }; + const delivered = await getSignedUrlOnEmulator( + app.get<{ emulatorHost?: string }>(storageConfig.KEY).emulatorHost, + url, + ); + + expect(delivered.body.toString()).toBe('ola!!'); + expect(delivered.headers['content-disposition']).toBe( + "attachment; filename*=UTF-8''novo.txt", + ); + }, + ); + }); +}); diff --git a/apps/api/test/support/items.ts b/apps/api/test/support/items.ts index 12a186e..3e571e9 100644 --- a/apps/api/test/support/items.ts +++ b/apps/api/test/support/items.ts @@ -59,6 +59,18 @@ export function itemRoutes(app: INestApplication) { ); }; + /** `PATCH /items/{id}`, com o token de acesso no cabeçalho quando ele é passado. */ + const rename = ( + id: string, + accessToken: string | undefined, + body: object, + ) => { + return withAuth( + request(app.getHttpServer()).patch(`/v1/items/${id}`).send(body), + accessToken, + ); + }; + /** * O id da Pasta raiz do Usuário. Ela nasce pelo caminho real, a primeira * listagem pelo atalho `root`, e só então é lida no banco. @@ -107,5 +119,13 @@ export function itemRoutes(app: INestApplication) { return rows.map((row) => row.id); }; - return { listChildren, downloadUrl, get, createFolder, rootId, seed }; + return { + listChildren, + downloadUrl, + get, + createFolder, + rename, + rootId, + seed, + }; } diff --git a/apps/web/app/(drive)/actions.ts b/apps/web/app/(drive)/actions.ts index 99afa8a..f05d743 100644 --- a/apps/web/app/(drive)/actions.ts +++ b/apps/web/app/(drive)/actions.ts @@ -14,7 +14,7 @@ import type { StartUploadInput, StartUploadResult, } from "@/lib/uploads/types"; -import { createFolder as makeFolder, getFolderItems } from "@/lib/dal/items"; +import { createFolder as makeFolder, getFolderItems, renameItem as changeName } from "@/lib/dal/items"; import { discardUpload, finishUpload, @@ -79,17 +79,18 @@ export type CreateFolderResult = | { ok: false; message: string }; /** - * O nome de uma pasta: sem os espaços das pontas, de 1 a 255 caracteres. As outras regras (`/`, + * O nome de um Item: sem os espaços das pontas, de 1 a 255 caracteres. As outras regras (`/`, * caracteres de controle, "." e "..") são da API, e o `validation_error` dela vira a mensagem - * de `INVALID_FOLDER_NAME`. + * de `INVALID_NAME`. */ -const folderNameSchema = z - .string() - .trim() - .min(1, "Informe um nome para a pasta.") - .max(255, "O nome pode ter até 255 caracteres."); +const itemNameSchema = (emptyMessage: string) => + z + .string() + .trim() + .min(1, emptyMessage) + .max(255, "O nome pode ter até 255 caracteres."); -const INVALID_FOLDER_NAME = +const INVALID_NAME = "O nome não pode ter “/” nem caracteres de controle, nem ser “.” ou “..”."; /** @@ -100,7 +101,7 @@ export async function createFolder( parentId: string, name: string, ): Promise { - const parsedName = folderNameSchema.safeParse(name); + const parsedName = itemNameSchema("Informe um nome para a pasta.").safeParse(name); const parsedParent = itemIdSchema.safeParse(parentId); if (!parsedName.success) { return { ok: false, message: parsedName.error.issues[0].message }; @@ -115,7 +116,39 @@ export async function createFolder( if (!result.ok) { const message = result.error.code === "validation_error" - ? INVALID_FOLDER_NAME + ? INVALID_NAME + : errorMessage(result.error.code); + return { ok: false, message }; + } + return { ok: true, name: result.data.name }; +} + +/** O que o "Renomear" devolve ao diálogo: o nome novo, como a API o gravou, ou o erro, em português. */ +export type RenameItemResult = + | { ok: true; name: string } + | { ok: false; message: string }; + +/** + * Renomeia um Item (pasta ou arquivo). É uma Server Action, um endpoint público: o nome é + * conferido aqui, e a Sessão, pela própria chamada à API. + */ +export async function renameItem( + id: string, + name: string, +): Promise { + const parsedName = itemNameSchema("Informe um nome.").safeParse(name); + const parsedId = itemIdSchema.safeParse(id); + if (!parsedName.success) { + return { ok: false, message: parsedName.error.issues[0].message }; + } + if (!parsedId.success) { + return { ok: false, message: errorMessage("validation_error") }; + } + const result = await changeName(parsedId.data, { name: parsedName.data }); + if (!result.ok) { + const message = + result.error.code === "validation_error" + ? INVALID_NAME : errorMessage(result.error.code); return { ok: false, message }; } diff --git a/apps/web/components/files/file-row.tsx b/apps/web/components/files/file-row.tsx index 914309e..27d7b07 100644 --- a/apps/web/components/files/file-row.tsx +++ b/apps/web/components/files/file-row.tsx @@ -1,6 +1,4 @@ import { - Download, - EllipsisVertical, File, FileMusic, FileText, @@ -11,8 +9,8 @@ import { } from "lucide-react"; import type { FileKind, FileRowData } from "@/components/files/format"; import { OpenableRow } from "@/components/files/openable-row"; -import { buttonIconClassName } from "@/components/ui/button-icon"; -import { Menu, MenuLink } from "@/components/ui/menu"; +import { renameItem } from "@/app/(drive)/actions"; +import { RowActions } from "@/components/files/row-actions"; import { folderPath } from "@/lib/drive"; const ROW_CLASS_NAME = @@ -32,8 +30,8 @@ export const FILE_KIND_ICONS: Record = { /** * O `{component.file-row}`: ícone do tipo, nome, tipo, tamanho e data. As colunas saem da * direita para a esquerda conforme a largura diminui, e o tamanho e a data vão para uma - * segunda linha, sob o nome. Uma linha de arquivo tem o menu "Mais ações", só com "Baixar"; a de - * pasta não tem, porque ainda não há ação de pasta, e abre a pasta com dois cliques ou com Enter. + * segunda linha, sob o nome. Toda linha tem o menu "Mais ações": "Baixar", só no arquivo, e + * "Renomear". A de pasta abre a pasta com dois cliques ou com Enter. * A seleção entra com a funcionalidade dela. */ export function FileRow({ row }: { row: FileRowData }) { @@ -69,20 +67,13 @@ export function FileRow({ row }: { row: FileRowData }) { {row.modifiedAt}
- {row.kind !== "folder" && ( - // O menu é de cliente, e a linha continua no servidor: os ícones atravessam a - // fronteira como elementos, já renderizados. - } - widthClassName="w-55" - > - } href={`/arquivos/${row.id}/baixar`}> - Baixar - - - )} + {/* O menu é de cliente, e a linha continua no servidor. */} +
); diff --git a/apps/web/components/files/openable-row.tsx b/apps/web/components/files/openable-row.tsx index 9dafdf9..5ccacf3 100644 --- a/apps/web/components/files/openable-row.tsx +++ b/apps/web/components/files/openable-row.tsx @@ -22,7 +22,11 @@ export function OpenableRow({ href, className, children }: OpenableRowProps) {
router.push(href)} + onDoubleClick={(event) => { + // O duplo clique no botão "Mais ações", no menu ou no diálogo que ele abre é deles. + if ((event.target as HTMLElement).closest('button, dialog, [role="menu"]')) return; + router.push(href); + }} onKeyDown={(event) => { // Enter num botão de dentro da linha é do botão, e não abre a pasta. if (event.key === "Enter" && event.target === event.currentTarget) { diff --git a/apps/web/components/files/rename-dialog.tsx b/apps/web/components/files/rename-dialog.tsx new file mode 100644 index 0000000..078ebfa --- /dev/null +++ b/apps/web/components/files/rename-dialog.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useActionState } from "react"; +import type { RenameItemResult } from "@/app/(drive)/actions"; +import { ButtonPrimary } from "@/components/ui/button-primary"; +import { ButtonSecondary } from "@/components/ui/button-secondary"; +import { Dialog } from "@/components/ui/dialog"; +import { TextField } from "@/components/ui/text-field"; +import { useToast } from "@/components/ui/toast"; + +type RenameDialogProps = { + /** O id do Item, pasta ou arquivo. */ + id: string; + /** O nome atual, que o campo mostra selecionado por inteiro. */ + name: string; + /** A Server Action que renomeia. */ + rename: (id: string, name: string) => Promise; + onClose: () => void; +}; + +/** O erro do último envio e o nome que foi enviado: o React limpa o formulário depois da ação, e o nome digitado volta. */ +type FormState = { error: string | null; name: string }; + +/** + * O diálogo de renomear, com o nome atual no campo, todo selecionado (a extensão de um arquivo + * inclusive). O erro de nome inválido aparece abaixo do campo, em português, sem fechar o diálogo. + * Renomeado o Item, o diálogo fecha, a lista é atualizada e um aviso confirma. + */ +export function RenameDialog({ id, name, rename, onClose }: RenameDialogProps) { + const router = useRouter(); + const toast = useToast(); + + const [state, submit, pending] = useActionState( + async (_previous, formData) => { + const raw = formData.get("name"); + const sent = typeof raw === "string" ? raw : ""; + const result = await rename(id, sent); + if (!result.ok) return { error: result.message, name: sent }; + toast.show(`Renomeado para “${result.name}”.`); + router.refresh(); + onClose(); + return { error: null, name: result.name }; + }, + { error: null, name }, + ); + + return ( + +
+ event.currentTarget.select()} + defaultValue={state.name} + error={state.error ?? undefined} + /> +
+ Cancelar + + Renomear + +
+ +
+ ); +} diff --git a/apps/web/components/files/row-actions.tsx b/apps/web/components/files/row-actions.tsx new file mode 100644 index 0000000..768607c --- /dev/null +++ b/apps/web/components/files/row-actions.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Download, EllipsisVertical, Pencil } from "lucide-react"; +import { useState } from "react"; +import type { RenameItemResult } from "@/app/(drive)/actions"; +import { RenameDialog } from "@/components/files/rename-dialog"; +import { buttonIconClassName } from "@/components/ui/button-icon"; +import { Menu, MenuItem, MenuLink } from "@/components/ui/menu"; + +type RowActionsProps = { + id: string; + name: string; + /** Só o arquivo se baixa. */ + downloadable: boolean; + /** A Server Action que renomeia. */ + rename: (id: string, name: string) => Promise; +}; + +/** + * O "Mais ações" de uma linha: "Baixar" (só no arquivo) e "Renomear". O diálogo mora aqui, e não + * dentro do menu, porque o menu desmonta os itens ao fechar e levaria o diálogo junto. + */ +export function RowActions({ id, name, downloadable, rename }: RowActionsProps) { + const [renaming, setRenaming] = useState(false); + return ( + <> + } + widthClassName="w-55" + > + {downloadable && ( + } href={`/arquivos/${id}/baixar`}> + Baixar + + )} + } closeOnClick onClick={() => setRenaming(true)}> + Renomear + + + {/* Montado só enquanto aberto: cada abertura recomeça com o nome atual selecionado. */} + {renaming && ( + setRenaming(false)} + /> + )} + + ); +} diff --git a/apps/web/components/ui/menu.tsx b/apps/web/components/ui/menu.tsx index 911bb40..fdc648e 100644 --- a/apps/web/components/ui/menu.tsx +++ b/apps/web/components/ui/menu.tsx @@ -154,17 +154,35 @@ type MenuItemProps = Omit< ComponentProps<"button">, "className" | "role" | "children" > & - ItemContentProps; + ItemContentProps & { + /** + * Fecha o menu depois do clique, para a ação que abre algo por cima (um diálogo). Fica de fora + * do item que envia um formulário, porque desmontá-lo no clique cancelaria o envio. + */ + closeOnClick?: boolean; + }; /** Uma ação do menu. Com `type="submit"`, envia o formulário em que está. */ export function MenuItem({ icon, children, type = "button", + closeOnClick = false, + onClick, ...props }: MenuItemProps) { + const close = useContext(CloseMenuContext); return ( - ); diff --git a/apps/web/e2e/rename.spec.ts b/apps/web/e2e/rename.spec.ts new file mode 100644 index 0000000..28c402e --- /dev/null +++ b/apps/web/e2e/rename.spec.ts @@ -0,0 +1,157 @@ +import { expect, test, type Page } from "@playwright/test"; +import { signIn } from "./support/account"; +import { seedFolder, seedRootFiles } from "./support/items"; +import { uniqueEmail } from "./support/mailpit"; + +/** As linhas da lista, sem a do cabeçalho. */ +const rowsOf = (page: Page) => + page.getByRole("table").getByRole("row").filter({ has: page.getByRole("cell") }); + +/** A linha do item de nome `name`. */ +const rowOf = (page: Page, name: string) => rowsOf(page).filter({ hasText: name }); + +/** Abre o diálogo de renomear a partir do menu "Mais ações" da linha. */ +async function openRename(page: Page, name: string) { + await rowOf(page, name).getByRole("button", { name: "Mais ações" }).click(); + await page.getByRole("menuitem", { name: "Renomear" }).click(); + return page.getByRole("dialog", { name: "Renomear" }); +} + +const LONG_AGO = new Date("2020-01-01T12:00:00Z"); + +test( + "o Usuário renomeia um arquivo pelo diálogo, que abre com o nome selecionado", + { tag: "@LUI-180-CA6" }, + async ({ page }) => { + const email = uniqueEmail(); + await signIn(page, email); + await seedRootFiles(email, [{ name: "relatorio.txt", updatedAt: LONG_AGO }]); + await page.reload(); + await expect(rowOf(page, "relatorio.txt")).toContainText("2020"); + + const dialog = await openRename(page, "relatorio.txt"); + const field = dialog.getByLabel("Nome"); + + await expect(field).toHaveValue("relatorio.txt"); + await expect(field).toBeFocused(); + expect( + await field.evaluate((input: HTMLInputElement) => [ + input.selectionStart, + input.selectionEnd, + input.value.length, + ]), + ).toEqual([0, "relatorio.txt".length, "relatorio.txt".length]); + + await field.fill(" balanco.txt "); + await dialog.getByRole("button", { name: "Renomear" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("status")).toContainText("Renomeado para “balanco.txt”."); + await expect(rowOf(page, "balanco.txt")).toHaveCount(1); + await expect(rowOf(page, "balanco.txt")).not.toContainText("2020"); + await expect(rowOf(page, "balanco.txt")).toContainText(String(new Date().getFullYear())); + await expect(rowOf(page, "relatorio.txt")).toHaveCount(0); + }, +); + +test( + "o Usuário renomeia uma pasta pelo menu da linha", + { tag: "@LUI-180-CA6" }, + async ({ page }) => { + const email = uniqueEmail(); + await signIn(page, email); + await seedFolder(email, "Fotos"); + await page.reload(); + + const dialog = await openRename(page, "Fotos"); + await expect(dialog.getByLabel("Nome")).toHaveValue("Fotos"); + await dialog.getByLabel("Nome").fill("Viagens"); + await dialog.getByRole("button", { name: "Renomear" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(rowOf(page, "Viagens")).toHaveCount(1); + await expect(rowOf(page, "Fotos")).toHaveCount(0); + }, +); + +test( + "o diálogo mostra o erro do nome inválido, sem fechar", + { tag: "@LUI-180-CA7" }, + async ({ page }) => { + const email = uniqueEmail(); + await signIn(page, email); + await seedRootFiles(email, [{ name: "relatorio.txt" }]); + await page.reload(); + const dialog = await openRename(page, "relatorio.txt"); + + await dialog.getByLabel("Nome").fill(" "); + await dialog.getByRole("button", { name: "Renomear" }).click(); + + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Informe um nome.")).toBeVisible(); + + await dialog.getByLabel("Nome").fill("a/b"); + await dialog.getByRole("button", { name: "Renomear" }).click(); + + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/não pode ter “\/”/)).toBeVisible(); + await dialog.getByRole("button", { name: "Cancelar" }).click(); + await expect(rowOf(page, "relatorio.txt")).toHaveCount(1); + }, +); + +test( + "os menus da pasta e do arquivo não têm Excluir", + { tag: "@LUI-180-CA8" }, + async ({ page }) => { + const email = uniqueEmail(); + await signIn(page, email); + await seedFolder(email, "Fotos"); + await seedRootFiles(email, [{ name: "relatorio.txt" }]); + await page.reload(); + + await rowOf(page, "Fotos").getByRole("button", { name: "Mais ações" }).click(); + await expect(page.getByRole("menuitem")).toHaveText(["Renomear"]); + await page.keyboard.press("Escape"); + + await rowOf(page, "relatorio.txt").getByRole("button", { name: "Mais ações" }).click(); + await expect(page.getByRole("menuitem")).toHaveText(["Baixar", "Renomear"]); + await expect(page.getByRole("menuitem", { name: "Excluir" })).toHaveCount(0); + }, +); + +test( + "o diálogo de renomear abre, confirma e fecha pelo teclado", + { tag: "@LUI-180-CA9" }, + async ({ page }) => { + const email = uniqueEmail(); + await signIn(page, email); + await seedRootFiles(email, [{ name: "relatorio.txt" }]); + await page.reload(); + const trigger = rowOf(page, "relatorio.txt").getByRole("button", { name: "Mais ações" }); + const menuItem = page.getByRole("menuitem", { name: "Baixar" }); + // Um Enter que chega antes da hidratação do menu se perde: o teclado tenta de novo até abrir. + await expect(async () => { + if ((await trigger.getAttribute("aria-expanded")) !== "true") { + await trigger.press("Enter"); + } + await expect(menuItem).toBeFocused({ timeout: 1000 }); + }).toPass(); + + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitem", { name: "Renomear" })).toBeFocused(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog", { name: "Renomear" }); + await expect(dialog.getByLabel("Nome")).toBeFocused(); + await page.keyboard.type("balanco.txt"); + await page.keyboard.press("Enter"); + + await expect(dialog).toHaveCount(0); + await expect(rowOf(page, "balanco.txt")).toHaveCount(1); + + await openRename(page, "balanco.txt"); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(rowOf(page, "balanco.txt")).toHaveCount(1); + }, +); diff --git a/apps/web/lib/api/items.ts b/apps/web/lib/api/items.ts index 386b26f..5e7bdf6 100644 --- a/apps/web/lib/api/items.ts +++ b/apps/web/lib/api/items.ts @@ -7,6 +7,7 @@ import type { Item, ItemDetail, ItemPage, + UpdateItemInput, } from "@/lib/api/types"; /* As rotas de Itens (docs/lld.md, seção 3.3). */ @@ -42,6 +43,19 @@ export function createFolder( return apiRequest("/folders", { method: "POST", body: input, accessToken }); } +/** Troca o nome de um Item ativo do Usuário. */ +export function updateItem( + accessToken: string, + id: string, + input: UpdateItemInput, +): Promise> { + return apiRequest(`/items/${encodeURIComponent(id)}`, { + method: "PATCH", + body: input, + accessToken, + }); +} + /** A URL assinada de leitura de um arquivo ativo do Usuário, com o nome original no download. */ export function getItemDownloadUrl( accessToken: string, diff --git a/apps/web/lib/api/types.ts b/apps/web/lib/api/types.ts index 7a6895c..3029b2c 100644 --- a/apps/web/lib/api/types.ts +++ b/apps/web/lib/api/types.ts @@ -122,6 +122,11 @@ export interface CreateFolderInput { parentId: string; } +/** O que `PATCH /items/{id}` pede: só o nome, por ora. */ +export interface UpdateItemInput { + name: string; +} + /** Uma página da listagem. O cursor é opaco, e nulo na última página. */ export interface ItemPage { items: Item[]; diff --git a/apps/web/lib/dal/items.ts b/apps/web/lib/dal/items.ts index 442fdc8..8db4833 100644 --- a/apps/web/lib/dal/items.ts +++ b/apps/web/lib/dal/items.ts @@ -4,6 +4,7 @@ import { getFolder as getApiFolder, getItemDownloadUrl, listChildren, + updateItem, } from "@/lib/api/items"; import type { ApiResult, @@ -12,6 +13,7 @@ import type { Item, ItemDetail, ItemPage, + UpdateItemInput, } from "@/lib/api/types"; import { withSession } from "@/lib/dal/session"; @@ -38,6 +40,14 @@ export function createFolder(input: CreateFolderInput): Promise> return withSession((token) => createApiFolder(token, input)); } +/** Troca o nome de um Item do Usuário da Sessão. */ +export function renameItem( + id: string, + input: UpdateItemInput, +): Promise> { + return withSession((token) => updateItem(token, id, input)); +} + /** A URL assinada de download de um arquivo do Usuário da Sessão. */ export function getDownloadUrl(id: string): Promise> { return withSession((token) => getItemDownloadUrl(token, id)); diff --git a/docs/lld.md b/docs/lld.md index 456fa04..531df42 100644 --- a/docs/lld.md +++ b/docs/lld.md @@ -308,7 +308,7 @@ Todo erro sai com `Content-Type: application/problem+json` e este corpo: | `GET /items/{id}` | | O item e a trilha de pastas até a raiz | | `GET /items/{id}/children` | `limit`, `cursor`, `type` (opcional, `folder`) | Lista de itens ativos da pasta | | `POST /folders` | `name`, `parentId` | 201, a pasta criada | -| `PATCH /items/{id}` | `name` e/ou `parentId` | O item atualizado | +| `PATCH /items/{id}` | `name` e/ou `parentId` | O item atualizado. Hoje só o `name` está implementado, e é obrigatório: o `parentId` entra com o mover, que o torna opcional. Renomear a Pasta raiz responde `validation_error`, e o atalho `root` vale no caminho. | | `DELETE /items/{id}` | | 204. Envia para a lixeira. | | `GET /trash` | `limit`, `cursor` | Itens enviados diretamente à lixeira | | `POST /items/{id}/restore` | | O item restaurado | @@ -681,7 +681,7 @@ Esta seção descreve o que a web apresenta e protege: as rotas e telas, a Sess | `/s/[token]/baixar/[id]` | Pública, rota que só redireciona | `GET /public/links/{token}/items/{id}/download-url` | | A detalhar (seção 7.6) | - **Casca do `(drive)` hoje:** a barra superior com a marca e o menu da conta (o e-mail e "Sair"), a navegação só com "Meus arquivos", aberta como gaveta pelo botão de menu abaixo de `lg`, o botão "Enviar", que escolhe arquivos e os envia num único `PUT` ao storage (o corte em chunks, a repetição, os vários arquivos e o arrastar e soltar entram com os arquivos grandes), o painel de envios, com o nome e o estado (enviando, concluído ou falhou, com o erro em português) de cada arquivo, num provider de cliente do layout, e a área de conteúdo, com o título da pasta. O que o item seguinte descreve e ainda não existe entra com a funcionalidade dona, sem link morto nem botão desabilitado. -- **Linha da lista hoje:** ícone do tipo, nome, tipo, tamanho e data de modificação, sem miniatura nem seleção. Uma linha de arquivo tem o botão "Mais ações", que abre o menu só com "Baixar"; a linha de pasta não tem o botão, porque ainda não há ação de pasta. O duplo clique não faz nada até a pré-visualização existir. O tipo é "Pasta" ou um rótulo tirado do `mimeType`, com os rótulos do Figma. O tamanho e a data saem formatados em português, a data no fuso `America/Sao_Paulo`, porque o Server Component roda em UTC. +- **Linha da lista hoje:** ícone do tipo, nome, tipo, tamanho e data de modificação, sem miniatura nem seleção. Toda linha tem o botão "Mais ações": o menu do arquivo tem "Baixar" e "Renomear", e o da pasta, só "Renomear" ("Mover para…" e "Excluir" entram com o mover e a lixeira). "Renomear" abre um diálogo com o nome atual todo selecionado, a extensão inclusive, e o erro de nome inválido aparece nele, sem fechá-lo. O duplo clique não faz nada até a pré-visualização existir. O tipo é "Pasta" ou um rótulo tirado do `mimeType`, com os rótulos do Figma. O tamanho e a data saem formatados em português, a data no fuso `America/Sao_Paulo`, porque o Server Component roda em UTC. - **Layout do `(drive)`:** mostra a navegação (início, lixeira, busca, planos), o indicador de uso da cota (`usedBytes` e `quotaBytes` de `GET /me`) e o painel de envios (seção 7.4). Uma conta somente leitura (`readOnly`) tem o envio desligado, com o motivo e o caminho para `/planos`. - **Estado na URL:** a pasta atual (`/pasta/[id]`), a busca (`q`) e o arquivo aberto na pré-visualização (`?ver=`) ficam na URL. - **Paginação das listas:** o Server Component mostra a primeira página, e um botão "Carregar mais", no fim da lista, busca a seguinte por Server Action e a acrescenta no cliente. O cursor não vai para a URL. Depois de um upload, o `router.refresh()` atualiza a primeira página, e as páginas acrescentadas ficam como estavam. O diálogo de compartilhar e o menu de cada item são estado de cliente. From 3f49b07483101fc3a991c4c6489e7af36f4ce4a7 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Fri, 16 Oct 2026 01:56:34 -0300 Subject: [PATCH 3/3] fix: make rename atomic and refresh rows loaded by "Carregar mais" (LUI-180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O renomear lia e gravava em operações separadas, deixando uma janela em que o item podia mudar de estado. Agora é um UPDATE condicional, e só sem linha afetada a leitura distingue Pasta raiz de not_found. O router.refresh() só refaz a primeira página, então a linha vinda de "Carregar mais" ficava com o nome antigo. O LoadMoreProvider ganhou reset(), chamado pelo diálogo de renomear. Co-Authored-By: Claude Sonnet 5.5 --- .../api/src/modules/items/items.repository.ts | 27 +++++++++-------- apps/web/components/files/load-more.tsx | 19 +++++++++++- apps/web/components/files/rename-dialog.tsx | 3 ++ apps/web/e2e/rename.spec.ts | 29 +++++++++++++++++++ 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/apps/api/src/modules/items/items.repository.ts b/apps/api/src/modules/items/items.repository.ts index 2f59142..5ba2670 100644 --- a/apps/api/src/modules/items/items.repository.ts +++ b/apps/api/src/modules/items/items.repository.ts @@ -185,27 +185,30 @@ export class ItemsRepository { } /** - * Troca o nome do Item ativo do Usuário e renova o `updatedAt`. A Pasta - * raiz, a única sem pai, fica de fora e é dita à parte. + * Troca o nome do Item ativo do Usuário e renova o `updatedAt`, num único + * `UPDATE` condicionado a ele ainda estar ativo e não ser a Pasta raiz, a + * única sem pai: uma mudança concorrente de estado não escapa da condição. + * Sem linha atualizada, a leitura só diz por quê: a Pasta raiz, dita à + * parte, ou nenhum Item ativo do Usuário. */ async renameActive( ownerId: string, id: string, name: string, ): Promise { - const found = await this.prisma.item.findFirst({ - where: { id, ownerId, status: 'active' }, - select: { parentId: true }, + const { count } = await this.prisma.item.updateMany({ + where: { id, ownerId, status: 'active', parentId: { not: null } }, + data: { name }, }); - if (!found) { - return { status: 'not_found' }; - } - if (found.parentId === null) { - return { status: 'root' }; + if (count === 0) { + const root = await this.prisma.item.findFirst({ + where: { id, ownerId, status: 'active', parentId: null }, + select: { id: true }, + }); + return { status: root ? 'root' : 'not_found' }; } - const row = await this.prisma.item.update({ + const row = await this.prisma.item.findUniqueOrThrow({ where: { id }, - data: { name }, select: ITEM_FIELDS, }); return { status: 'renamed', item: toItem(row) }; diff --git a/apps/web/components/files/load-more.tsx b/apps/web/components/files/load-more.tsx index 6af0ef1..9781ffc 100644 --- a/apps/web/components/files/load-more.tsx +++ b/apps/web/components/files/load-more.tsx @@ -23,10 +23,21 @@ interface LoadMoreState { pending: boolean; error: string | null; loadMore: () => void; + /** Descarta as páginas acrescentadas e volta à primeira, a do servidor. */ + reset: () => void; } const LoadMoreContext = createContext(null); +/** + * Quem muda uma linha por uma ação (como renomear) chama o que esta função devolve, para as + * páginas acrescentadas não mostrarem o dado antigo: o `router.refresh()` só refaz a primeira + * página. Fora de uma lista com "Carregar mais", não faz nada. + */ +export function useResetLoadedPages(): () => void { + return use(LoadMoreContext)?.reset ?? (() => {}); +} + function useLoadMore(): LoadMoreState { const state = use(LoadMoreContext); if (!state) throw new Error("Use dentro de um LoadMoreProvider."); @@ -70,9 +81,15 @@ export function LoadMoreProvider({ }); } + function reset() { + setRows([]); + setCursor(initialCursor); + setError(null); + } + return ( {children} diff --git a/apps/web/components/files/rename-dialog.tsx b/apps/web/components/files/rename-dialog.tsx index 078ebfa..d0a3b25 100644 --- a/apps/web/components/files/rename-dialog.tsx +++ b/apps/web/components/files/rename-dialog.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useActionState } from "react"; import type { RenameItemResult } from "@/app/(drive)/actions"; +import { useResetLoadedPages } from "@/components/files/load-more"; import { ButtonPrimary } from "@/components/ui/button-primary"; import { ButtonSecondary } from "@/components/ui/button-secondary"; import { Dialog } from "@/components/ui/dialog"; @@ -30,6 +31,7 @@ type FormState = { error: string | null; name: string }; export function RenameDialog({ id, name, rename, onClose }: RenameDialogProps) { const router = useRouter(); const toast = useToast(); + const resetLoadedPages = useResetLoadedPages(); const [state, submit, pending] = useActionState( async (_previous, formData) => { @@ -38,6 +40,7 @@ export function RenameDialog({ id, name, rename, onClose }: RenameDialogProps) { const result = await rename(id, sent); if (!result.ok) return { error: result.message, name: sent }; toast.show(`Renomeado para “${result.name}”.`); + resetLoadedPages(); router.refresh(); onClose(); return { error: null, name: result.name }; diff --git a/apps/web/e2e/rename.spec.ts b/apps/web/e2e/rename.spec.ts index 28c402e..ac48709 100644 --- a/apps/web/e2e/rename.spec.ts +++ b/apps/web/e2e/rename.spec.ts @@ -155,3 +155,32 @@ test( await expect(rowOf(page, "balanco.txt")).toHaveCount(1); }, ); + +test( + "renomear um item de uma página carregada por Carregar mais não deixa o nome antigo na lista", + async ({ page }) => { + const email = uniqueEmail(); + await signIn(page, email); + await seedRootFiles( + email, + Array.from({ length: 51 }, (_, index) => ({ + name: `arquivo-${String(index + 1).padStart(3, "0")}.txt`, + })), + ); + await page.reload(); + await page.getByRole("button", { name: "Carregar mais" }).click(); + await expect(rowOf(page, "arquivo-051.txt")).toHaveCount(1); + + const dialog = await openRename(page, "arquivo-051.txt"); + // O novo nome continua depois da primeira página: o servidor devolve os mesmos 50 itens, e + // só o descarte das páginas acrescentadas tira o nome antigo da tela. + await dialog.getByLabel("Nome").fill("zzz-ultimo.txt"); + await dialog.getByRole("button", { name: "Renomear" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(rowOf(page, "arquivo-051.txt")).toHaveCount(0); + await page.getByRole("button", { name: "Carregar mais" }).click(); + await expect(rowOf(page, "zzz-ultimo.txt")).toHaveCount(1); + await expect(rowOf(page, "arquivo-051.txt")).toHaveCount(0); + }, +);