diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ca59e22..79c2546 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": { @@ -702,10 +811,10 @@ "required": false, "in": "query", "schema": { + "type": "string", "enum": [ "folder" - ], - "type": "string" + ] } } ], @@ -1598,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": { @@ -1650,6 +1740,38 @@ "trail" ] }, + "UpdateItemDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "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..5ba2670 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,36 @@ export class ItemsRepository { }); } + /** + * 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 { count } = await this.prisma.item.updateMany({ + where: { id, ownerId, status: 'active', parentId: { not: null } }, + data: { name }, + }); + 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.findUniqueOrThrow({ + where: { id }, + 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/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/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..d0a3b25 --- /dev/null +++ b/apps/web/components/files/rename-dialog.tsx @@ -0,0 +1,72 @@ +"use client"; + +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"; +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 resetLoadedPages = useResetLoadedPages(); + + 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}”.`); + resetLoadedPages(); + 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..ac48709 --- /dev/null +++ b/apps/web/e2e/rename.spec.ts @@ -0,0 +1,186 @@ +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); + }, +); + +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); + }, +); 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. 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".