From 287aab6bedf8ecdb6a232eb18be16130c82c81aa Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Mon, 15 Sep 2025 16:01:16 +0300 Subject: [PATCH 1/2] feat: add project tags in dashboard Ref https://github.com/webstudio-is/webstudio/issues/860 --- .../features/pages/page-settings.stories.tsx | 1 + .../app/dashboard/dashboard.stories.tsx | 2 + apps/builder/app/dashboard/dashboard.tsx | 2 + .../app/dashboard/projects/project-card.tsx | 180 ++++++++++++++++-- .../app/dashboard/projects/projects.tsx | 54 +++++- .../app/dashboard/search/search-results.tsx | 1 + apps/builder/app/dashboard/shared/types.ts | 1 + apps/builder/app/routes/_ui.(builder).tsx | 4 +- apps/builder/app/routes/_ui.dashboard.tsx | 18 +- .../app/routes/rest.build.$buildId.tsx | 4 +- .../app/routes/rest.buildId.$projectId.tsx | 4 +- .../app/routes/rest.data.$projectId.ts | 4 +- apps/builder/app/routes/rest.patch.ts | 4 +- .../plugin-webflow/plugin-webflow.test.tsx | 1 + apps/builder/app/shared/db/canvas.server.ts | 4 +- packages/domain/src/db/domain.ts | 4 +- packages/domain/src/trpc/domain.ts | 8 +- packages/postgrest/package.json | 2 +- .../postgrest/src/__generated__/db-types.ts | 22 +++ .../20250913204036_project_tags/migration.sql | 26 +++ packages/prisma-client/prisma/schema.prisma | 1 + packages/project/src/db/index.ts | 2 - packages/project/src/db/project.ts | 17 ++ packages/project/src/index.server.ts | 5 +- packages/project/src/index.ts | 2 +- packages/project/src/trpc/index.ts | 1 - packages/project/src/trpc/project-router.ts | 33 +++- 27 files changed, 354 insertions(+), 53 deletions(-) create mode 100644 packages/prisma-client/prisma/migrations/20250913204036_project_tags/migration.sql delete mode 100644 packages/project/src/db/index.ts delete mode 100644 packages/project/src/trpc/index.ts diff --git a/apps/builder/app/builder/features/pages/page-settings.stories.tsx b/apps/builder/app/builder/features/pages/page-settings.stories.tsx index b354f13190ef..cd66290aecef 100644 --- a/apps/builder/app/builder/features/pages/page-settings.stories.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.stories.tsx @@ -63,6 +63,7 @@ $project.set({ isDeleted: false, userId: "userId", domain: "new-2x9tcd", + tags: [], marketplaceApprovalStatus: "UNLISTED", diff --git a/apps/builder/app/dashboard/dashboard.stories.tsx b/apps/builder/app/dashboard/dashboard.stories.tsx index 69f2b7e2a7b0..759f3e68fc6b 100644 --- a/apps/builder/app/dashboard/dashboard.stories.tsx +++ b/apps/builder/app/dashboard/dashboard.stories.tsx @@ -49,6 +49,7 @@ const projects = [ previewImageAssetId: "", latestBuildVirtual: null, marketplaceApprovalStatus: "UNLISTED" as const, + tags: [], } as DashboardProject, ]; @@ -58,6 +59,7 @@ const data = { userPlanFeatures, publisherHost: "https://wstd.work", projects, + tags: [], }; export const Welcome: StoryFn = () => { diff --git a/apps/builder/app/dashboard/dashboard.tsx b/apps/builder/app/dashboard/dashboard.tsx index 351efd1f05c9..a0ecda5d40b7 100644 --- a/apps/builder/app/dashboard/dashboard.tsx +++ b/apps/builder/app/dashboard/dashboard.tsx @@ -160,6 +160,7 @@ export const Dashboard = () => { projectToClone, projects, templates, + tags, } = data; const hasProjects = projects.length > 0; const view = getView(location.pathname, hasProjects); @@ -235,6 +236,7 @@ export const Dashboard = () => { projects={projects} hasProPlan={userPlanFeatures.hasProPlan} publisherHost={publisherHost} + tags={tags} /> )} {view === "templates" && } diff --git a/apps/builder/app/dashboard/projects/project-card.tsx b/apps/builder/app/dashboard/projects/project-card.tsx index 8318b44840ab..be26a2f89eea 100644 --- a/apps/builder/app/dashboard/projects/project-card.tsx +++ b/apps/builder/app/dashboard/projects/project-card.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from "react"; +import { useRevalidator } from "react-router-dom"; +import { useEffect, useId, useState } from "react"; import { DropdownMenu, DropdownMenuTrigger, @@ -14,10 +15,23 @@ import { rawTheme, Link, Box, + Dialog, + DialogContent, + DialogTitle, + Button, + DialogActions, + DialogClose, + Checkbox, + CheckboxAndLabel, + Label, + InputField, + DialogTitleActions, + Grid, } from "@webstudio-is/design-system"; -import { InfoCircleIcon, EllipsesIcon } from "@webstudio-is/icons"; +import { InfoCircleIcon, EllipsesIcon, PlusIcon } from "@webstudio-is/icons"; import type { DashboardProject } from "@webstudio-is/dashboard"; import { builderUrl } from "~/shared/router-utils"; +import { nativeClient } from "~/shared/trpc/trpc-client"; import { RenameProjectDialog, DeleteProjectDialog, @@ -31,6 +45,128 @@ import { import { Spinner } from "../shared/spinner"; import { Card, CardContent, CardFooter } from "../shared/card"; +const TagsDialogContent = ({ + projectId, + availableTags, + projectTags, + onOpenChange, +}: { + projectId: string; + availableTags: string[]; + projectTags: string[]; + onOpenChange: (isOpen: boolean) => void; +}) => { + const revalidator = useRevalidator(); + const tagId = useId(); + const [tags, setTags] = useState(availableTags); + return ( +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const newTags = formData + .getAll("tag") + .map((item) => String(item).trim()) + .filter((item) => item); + await nativeClient.project.updateTags.mutate({ + projectId, + tags: newTags, + }); + revalidator.revalidate(); + onOpenChange(false); + }} + > + + + + + + + + ); +}; + +const TagsDialog = ({ + projectId, + availableTags, + projectTags, + isOpen, + onOpenChange, +}: { + projectId: string; + availableTags: string[]; + projectTags: string[]; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}) => { + return ( + + + + + + ); +}; + const infoIconStyle = css({ flexShrink: 0 }); const PublishedLink = ({ @@ -64,12 +200,14 @@ const Menu = ({ onRename, onDuplicate, onShare, + onUpdateTags, }: { tabIndex: number; onDelete: () => void; onRename: () => void; onDuplicate: () => void; onShare: () => void; + onUpdateTags: () => void; }) => { const [isOpen, setIsOpen] = useState(false); return ( @@ -87,6 +225,7 @@ const Menu = ({ Duplicate Rename Share + Tags Delete @@ -105,6 +244,7 @@ type ProjectCardProps = { project: DashboardProject; hasProPlan: boolean; publisherHost: string; + tags: string[]; }; export const ProjectCard = ({ @@ -116,6 +256,7 @@ export const ProjectCard = ({ createdAt, latestBuildVirtual, previewImageAsset, + tags, }, hasProPlan, publisherHost, @@ -124,6 +265,7 @@ export const ProjectCard = ({ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const [isTagsDialogOpen, setIsTagsDialogOpen] = useState(false); const [isHidden, setIsHidden] = useState(false); const handleCloneProject = useCloneProject(id); const [isTransitioning, setIsTransitioning] = useState(false); @@ -180,6 +322,20 @@ export const ProjectCard = ({ )} {isTransitioning && } + + {tags?.map((tag) => ( +
{tag}
+ ))} +
@@ -225,16 +381,11 @@ export const ProjectCard = ({ { - setIsDeleteDialogOpen(true); - }} - onRename={() => { - setIsRenameDialogOpen(true); - }} - onShare={() => { - setIsShareDialogOpen(true); - }} + onDelete={() => setIsDeleteDialogOpen(true)} + onRename={() => setIsRenameDialogOpen(true)} + onShare={() => setIsShareDialogOpen(true)} onDuplicate={handleCloneProject} + onUpdateTags={() => setIsTagsDialogOpen(true)} /> + ); }; diff --git a/apps/builder/app/dashboard/projects/projects.tsx b/apps/builder/app/dashboard/projects/projects.tsx index 2f22cbf5ff35..7560e3680f49 100644 --- a/apps/builder/app/dashboard/projects/projects.tsx +++ b/apps/builder/app/dashboard/projects/projects.tsx @@ -1,4 +1,6 @@ import { + Box, + Button, Flex, Grid, List, @@ -11,11 +13,14 @@ import type { DashboardProject } from "@webstudio-is/dashboard"; import { ProjectCard } from "./project-card"; import { CreateProject } from "./project-dialogs"; import { Header, Main } from "../shared/layout"; +import { useSearchParams } from "react-router-dom"; +import { setIsSubsetOf } from "~/shared/shim"; export const ProjectsGrid = ({ projects, hasProPlan, publisherHost, + tags, }: ProjectsProps) => { return ( @@ -33,6 +38,7 @@ export const ProjectsGrid = ({ project={project} hasProPlan={hasProPlan} publisherHost={publisherHost} + tags={tags} /> ); @@ -46,9 +52,18 @@ type ProjectsProps = { projects: Array; hasProPlan: boolean; publisherHost: string; + tags: string[]; }; export const Projects = (props: ProjectsProps) => { + const [searchParams, setSearchParams] = useSearchParams(); + const selectedTags = searchParams.getAll("tag"); + let projects = props.projects; + if (selectedTags.length > 0) { + projects = projects.filter((project) => + setIsSubsetOf(new Set(selectedTags), new Set(project.tags)) + ); + } return (
@@ -60,12 +75,43 @@ export const Projects = (props: ProjectsProps) => {
- + {props.tags.map((tag) => ( + + ))} + + +
); }; diff --git a/apps/builder/app/dashboard/search/search-results.tsx b/apps/builder/app/dashboard/search/search-results.tsx index f2a07617743f..70f9494d11f8 100644 --- a/apps/builder/app/dashboard/search/search-results.tsx +++ b/apps/builder/app/dashboard/search/search-results.tsx @@ -63,6 +63,7 @@ export const SearchResults = (props: DashboardData) => { projects={results.projects} hasProPlan={userPlanFeatures.hasProPlan} publisherHost={publisherHost} + tags={props.tags} /> )} diff --git a/apps/builder/app/dashboard/shared/types.ts b/apps/builder/app/dashboard/shared/types.ts index e631b9e18b68..5ca9499d7603 100644 --- a/apps/builder/app/dashboard/shared/types.ts +++ b/apps/builder/app/dashboard/shared/types.ts @@ -13,4 +13,5 @@ export type DashboardData = { id: string; title: string; }; + tags: string[]; }; diff --git a/apps/builder/app/routes/_ui.(builder).tsx b/apps/builder/app/routes/_ui.(builder).tsx index cfcd28ed6af0..365d23f9be2f 100644 --- a/apps/builder/app/routes/_ui.(builder).tsx +++ b/apps/builder/app/routes/_ui.(builder).tsx @@ -13,7 +13,7 @@ import { } from "@remix-run/server-runtime"; import { loadBuildIdAndVersionByProjectId } from "@webstudio-is/project-build/index.server"; -import { db } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import { db as authDb } from "@webstudio-is/authorization-token/index.server"; import { @@ -120,7 +120,7 @@ export const loader = async (loaderArgs: LoaderFunctionArgs) => { } const start = Date.now(); - const project = await db.project.loadById(projectId, context); + const project = await projectApi.loadById(projectId, context); if (project === null) { throw new Response(`Project "${projectId}" not found`, { diff --git a/apps/builder/app/routes/_ui.dashboard.tsx b/apps/builder/app/routes/_ui.dashboard.tsx index 0ded4d84dfe6..f797e8530413 100644 --- a/apps/builder/app/routes/_ui.dashboard.tsx +++ b/apps/builder/app/routes/_ui.dashboard.tsx @@ -13,7 +13,7 @@ import { type AppContext, } from "@webstudio-is/trpc-interface/index.server"; import { db as authDb } from "@webstudio-is/authorization-token/index.server"; -import { db } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import { parseBuilderUrl } from "@webstudio-is/http-client"; import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server"; import { builderUrl, isDashboard, loginPath } from "~/shared/router-utils"; @@ -121,7 +121,7 @@ const getProjectToClone = async (request: Request, context: AppContext) => { throw new AuthorizationError("You don't have access to clone this project"); } - const project = await db.project.loadById( + const project = await projectApi.loadById( token.projectId, await context.createTokenContext(projectToCloneAuthToken) ); @@ -144,6 +144,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { await loadDashboardData(request); const projectToClone = await getProjectToClone(request, context); + const projectTag = await context.postgrest.client + .from("project_tag") + .select("tag") + .eq("user_id", user.id); + if (projectTag.error) { + throw projectTag.error; + } + const tags: string[] = []; + for (const item of projectTag.data) { + if (item.tag) { + tags.push(item.tag); + } + } return { user, @@ -153,6 +166,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { publisherHost: env.PUBLISHER_HOST, origin, projectToClone, + tags, }; }; diff --git a/apps/builder/app/routes/rest.build.$buildId.tsx b/apps/builder/app/routes/rest.build.$buildId.tsx index afbb76a4281c..d419cec4e1c1 100644 --- a/apps/builder/app/routes/rest.build.$buildId.tsx +++ b/apps/builder/app/routes/rest.build.$buildId.tsx @@ -4,7 +4,7 @@ import { type TypedResponse, } from "@remix-run/server-runtime"; import type { Data } from "@webstudio-is/http-client"; -import { db as projectDb } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import { loadProductionCanvasData } from "~/shared/db"; import { createContext } from "~/shared/context.server"; import { getUserById, type User } from "~/shared/db/user.server"; @@ -46,7 +46,7 @@ export const loader = async ({ const pagesCanvasData = await loadProductionCanvasData(buildId, context); - const project = await projectDb.project.loadById( + const project = await projectApi.loadById( pagesCanvasData.build.projectId, context ); diff --git a/apps/builder/app/routes/rest.buildId.$projectId.tsx b/apps/builder/app/routes/rest.buildId.$projectId.tsx index a6d43b6efe83..30b012826e72 100644 --- a/apps/builder/app/routes/rest.buildId.$projectId.tsx +++ b/apps/builder/app/routes/rest.buildId.$projectId.tsx @@ -3,7 +3,7 @@ import { type LoaderFunctionArgs, type TypedResponse, } from "@remix-run/server-runtime"; -import { db as projectDb } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import { allowedDestinations } from "~/services/destinations.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { createContext } from "~/shared/context.server"; @@ -40,7 +40,7 @@ export const loader = async ({ // @todo Create a context without user authentication information. const context = await createContext(request); - const project = await projectDb.project.loadById(projectId, context); + const project = await projectApi.loadById(projectId, context); const buildId = project.latestBuildVirtual?.buildId ?? null; return { diff --git a/apps/builder/app/routes/rest.data.$projectId.ts b/apps/builder/app/routes/rest.data.$projectId.ts index cc90ec0a8270..a0ce881bb976 100644 --- a/apps/builder/app/routes/rest.data.$projectId.ts +++ b/apps/builder/app/routes/rest.data.$projectId.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { db } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import { loadDevBuildByProjectId } from "@webstudio-is/project-build/index.server"; import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server"; import { createContext } from "~/shared/context.server"; @@ -16,7 +16,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { throw new Error("Project id undefined"); } const context = await createContext(request); - const project = await db.project.loadById(params.projectId, context); + const project = await projectApi.loadById(params.projectId, context); if (project === null) { throw new Error(`Project "${params.projectId}" not found`); } diff --git a/apps/builder/app/routes/rest.patch.ts b/apps/builder/app/routes/rest.patch.ts index 920c07647b1f..3916a0c82f90 100644 --- a/apps/builder/app/routes/rest.patch.ts +++ b/apps/builder/app/routes/rest.patch.ts @@ -44,7 +44,7 @@ import { authorizeProject, } from "@webstudio-is/trpc-interface/index.server"; import { createContext } from "~/shared/context.server"; -import { db } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import type { Database } from "@webstudio-is/postrest/index.server"; import { publicStaticEnv } from "~/env/env.static"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; @@ -417,7 +417,7 @@ export const action = async ({ } if (previewImageAssetId !== undefined) { - await db.project.updatePreviewImage( + await projectApi.updatePreviewImage( { assetId: previewImageAssetId, projectId }, context ); diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx index e62d7a6f9a77..c481af2a9db4 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx @@ -105,6 +105,7 @@ beforeEach(() => { domainsVirtual: [], latestBuildVirtual: null, previewImageAssetId: null, + tags: [], }); $breakpoints.set( diff --git a/apps/builder/app/shared/db/canvas.server.ts b/apps/builder/app/shared/db/canvas.server.ts index 8345c1563c69..b6e6165314ee 100644 --- a/apps/builder/app/shared/db/canvas.server.ts +++ b/apps/builder/app/shared/db/canvas.server.ts @@ -3,7 +3,7 @@ import { loadBuildById } from "@webstudio-is/project-build/index.server"; import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server"; import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; import { findPageByIdOrPath, getStyleDeclKey } from "@webstudio-is/sdk"; -import { db as projectDb } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; const getPair = (item: Item): [string, Item] => [ item.id, @@ -26,7 +26,7 @@ export const loadProductionCanvasData = async ( throw new Error("The project is not published"); } - const project = await projectDb.project.loadById(build.projectId, context); + const project = await projectApi.loadById(build.projectId, context); const currentProjectDomains = project.domainsVirtual; diff --git a/packages/domain/src/db/domain.ts b/packages/domain/src/db/domain.ts index d9e4081b017a..c4473492b9ed 100644 --- a/packages/domain/src/db/domain.ts +++ b/packages/domain/src/db/domain.ts @@ -3,7 +3,7 @@ import { type AppContext, AuthorizationError, } from "@webstudio-is/trpc-interface/index.server"; -import { db as projectDb } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import { validateDomain } from "./validate"; import { cnameFromUserId } from "./cname-from-user-id"; import type { Project } from "@webstudio-is/project"; @@ -35,7 +35,7 @@ export const create = async ( ); } - const project = await projectDb.project.loadById(props.projectId, context); + const project = await projectApi.loadById(props.projectId, context); const { userId: ownerId } = project; diff --git a/packages/domain/src/trpc/domain.ts b/packages/domain/src/trpc/domain.ts index 2b140ea1d979..2d1f59068c73 100644 --- a/packages/domain/src/trpc/domain.ts +++ b/packages/domain/src/trpc/domain.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { nanoid } from "nanoid"; -import { db as projectDb } from "@webstudio-is/project/index.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import { createProductionBuild } from "@webstudio-is/project-build/index.server"; import { router, procedure } from "@webstudio-is/trpc-interface/index.server"; import { Templates } from "@webstudio-is/sdk"; @@ -41,7 +41,7 @@ export const domainRouter = router({ .input(z.object({ projectId: z.string() })) .query(async ({ input, ctx }) => { try { - const project = await projectDb.project.loadById(input.projectId, ctx); + const project = await projectApi.loadById(input.projectId, ctx); return { success: true, @@ -71,7 +71,7 @@ export const domainRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - const project = await projectDb.project.loadById(input.projectId, ctx); + const project = await projectApi.loadById(input.projectId, ctx); const name = `${project.id}-${nanoid()}.zip`; @@ -169,7 +169,7 @@ export const domainRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - await projectDb.project.updateDomain( + await projectApi.updateDomain( { id: input.projectId, domain: input.domain, diff --git a/packages/postgrest/package.json b/packages/postgrest/package.json index 629d224e61e4..1e5813755d6f 100644 --- a/packages/postgrest/package.json +++ b/packages/postgrest/package.json @@ -16,7 +16,7 @@ "sideEffects": false, "scripts": { "typecheck": "tsc", - "generate-types": "pnpx supabase gen types --lang=typescript --db-url postgresql://postgres:pass@localhost/webstudio > ./src/__generated__/db-types.ts", + "generate-types": "pnpx supabase gen types --lang=typescript --db-url postgresql://postgres:pass@localhost/webstudio > ./src/__generated__/db-types.ts && prettier --write ./src/__generated__/db-types.ts", "playground": "pnpm tsx --env-file ../../apps/builder/.env", "db-test": "docker run --rm --network host -v ./supabase/tests:/tests -e PGOPTIONS='--search_path=pgtap,public' supabase/pg_prove:3.36 pg_prove -d ${DIRECT_URL:-postgresql://postgres:pass@localhost/webstudio} --ext .sql /tests" }, diff --git a/packages/postgrest/src/__generated__/db-types.ts b/packages/postgrest/src/__generated__/db-types.ts index 24f69967cca9..3294d79cc8d3 100644 --- a/packages/postgrest/src/__generated__/db-types.ts +++ b/packages/postgrest/src/__generated__/db-types.ts @@ -516,6 +516,7 @@ export type Database = { isDeleted: boolean; marketplaceApprovalStatus: Database["public"]["Enums"]["MarketplaceApprovalStatus"]; previewImageAssetId: string | null; + tags: string[] | null; title: string; userId: string | null; }; @@ -526,6 +527,7 @@ export type Database = { isDeleted?: boolean; marketplaceApprovalStatus?: Database["public"]["Enums"]["MarketplaceApprovalStatus"]; previewImageAssetId?: string | null; + tags?: string[] | null; title: string; userId?: string | null; }; @@ -536,6 +538,7 @@ export type Database = { isDeleted?: boolean; marketplaceApprovalStatus?: Database["public"]["Enums"]["MarketplaceApprovalStatus"]; previewImageAssetId?: string | null; + tags?: string[] | null; title?: string; userId?: string | null; }; @@ -748,6 +751,7 @@ export type Database = { | Database["public"]["Enums"]["MarketplaceApprovalStatus"] | null; previewImageAssetId: string | null; + tags: string[] | null; title: string | null; userId: string | null; }; @@ -761,6 +765,7 @@ export type Database = { | Database["public"]["Enums"]["MarketplaceApprovalStatus"] | null; previewImageAssetId?: string | null; + tags?: string[] | null; title?: string | null; userId?: string | null; }; @@ -774,6 +779,7 @@ export type Database = { | Database["public"]["Enums"]["MarketplaceApprovalStatus"] | null; previewImageAssetId?: string | null; + tags?: string[] | null; title?: string | null; userId?: string | null; }; @@ -818,6 +824,21 @@ export type Database = { }, ]; }; + project_tag: { + Row: { + tag: string | null; + user_id: string | null; + }; + Relationships: [ + { + foreignKeyName: "Project_userId_fkey"; + columns: ["user_id"]; + isOneToOne: false; + referencedRelation: "User"; + referencedColumns: ["id"]; + }, + ]; + }; published_builds: { Row: { buildId: string | null; @@ -895,6 +916,7 @@ export type Database = { isDeleted: boolean; marketplaceApprovalStatus: Database["public"]["Enums"]["MarketplaceApprovalStatus"]; previewImageAssetId: string | null; + tags: string[] | null; title: string; userId: string | null; }; diff --git a/packages/prisma-client/prisma/migrations/20250913204036_project_tags/migration.sql b/packages/prisma-client/prisma/migrations/20250913204036_project_tags/migration.sql new file mode 100644 index 000000000000..e4b8c68a1068 --- /dev/null +++ b/packages/prisma-client/prisma/migrations/20250913204036_project_tags/migration.sql @@ -0,0 +1,26 @@ +ALTER TABLE "Project" ADD COLUMN "tags" TEXT[]; + +DROP VIEW IF EXISTS "DashboardProject"; +CREATE VIEW "DashboardProject" AS +SELECT + id, + title, + tags, + domain, + "userId", + "isDeleted", + "createdAt", + "previewImageAssetId", + "marketplaceApprovalStatus", + (EXISTS ( + SELECT 1 + FROM "Build" + WHERE "Build"."projectId" = "Project".id + AND "Build".deployment IS NOT NULL) + ) AS "isPublished" +FROM "Project"; + +DROP VIEW IF EXISTS project_tag; +CREATE VIEW project_tag AS +SELECT DISTINCT "userId" as user_id, unnest(tags) AS tag +FROM "Project" AS project; diff --git a/packages/prisma-client/prisma/schema.prisma b/packages/prisma-client/prisma/schema.prisma index 0748f0c7848a..e84e6d7a015d 100644 --- a/packages/prisma-client/prisma/schema.prisma +++ b/packages/prisma-client/prisma/schema.prisma @@ -144,6 +144,7 @@ model Project { id String @id @default(uuid()) createdAt DateTime @default(now()) @db.Timestamptz(3) title String + tags String[] domain String @unique user User? @relation(fields: [userId], references: [id]) userId String? diff --git a/packages/project/src/db/index.ts b/packages/project/src/db/index.ts deleted file mode 100644 index fd0f04972d9b..000000000000 --- a/packages/project/src/db/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import * as project from "./project"; -export { project }; diff --git a/packages/project/src/db/project.ts b/packages/project/src/db/project.ts index 70ef6bebf8f8..1bd73338422b 100644 --- a/packages/project/src/db/project.ts +++ b/packages/project/src/db/project.ts @@ -310,3 +310,20 @@ export const setMarketplaceApprovalStatus = async ( } return updatedProject.data; }; + +export const updateProjectTags = async ( + { projectId, tags }: { projectId: Project["id"]; tags: string[] }, + context: AppContext +) => { + await assertEditPermission(projectId, context); + const updatedProject = await context.postgrest.client + .from("Project") + .update({ tags }) + .eq("id", projectId) + .select() + .single(); + if (updatedProject.error) { + throw updatedProject.error; + } + return updatedProject.data; +}; diff --git a/packages/project/src/index.server.ts b/packages/project/src/index.server.ts index 8dca4d08edc1..ca744393c126 100644 --- a/packages/project/src/index.server.ts +++ b/packages/project/src/index.server.ts @@ -1,3 +1,2 @@ -import * as dbFunctions from "./db"; -export const db = dbFunctions; -export * from "./trpc"; +export * from "./db/project"; +export * from "./trpc/project-router"; diff --git a/packages/project/src/index.ts b/packages/project/src/index.ts index c924fefcdcab..55da3b9106d5 100644 --- a/packages/project/src/index.ts +++ b/packages/project/src/index.ts @@ -1,4 +1,4 @@ export * from "./shared/schema"; export type { Project } from "./db/project"; -export type { ProjectRouter } from "./trpc"; +export type { ProjectRouter } from "./trpc/project-router"; export { validateProjectDomain } from "./db/project-domain"; diff --git a/packages/project/src/trpc/index.ts b/packages/project/src/trpc/index.ts deleted file mode 100644 index 1f54bc136242..000000000000 --- a/packages/project/src/trpc/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./project-router"; diff --git a/packages/project/src/trpc/project-router.ts b/packages/project/src/trpc/project-router.ts index d1ffead21b57..ae373890a19f 100644 --- a/packages/project/src/trpc/project-router.ts +++ b/packages/project/src/trpc/project-router.ts @@ -1,4 +1,4 @@ -import * as db from "../db"; +import * as projectApi from "../db/project"; import { z } from "zod"; import { router, @@ -18,14 +18,16 @@ export const projectRouter = router({ ) .mutation(async ({ input, ctx }) => { // @todo pass ctx for authorization - return await db.project.rename(input, ctx); + return await projectApi.rename(input, ctx); }), + delete: procedure .input(z.object({ projectId: z.string() })) .mutation(async ({ input, ctx }) => { // @todo pass ctx for authorization - return await db.project.markAsDeleted(input.projectId, ctx); + return await projectApi.markAsDeleted(input.projectId, ctx); }), + clone: procedure .input( z.object({ @@ -38,14 +40,15 @@ export const projectRouter = router({ const sourceContext = input.authToken ? await ctx.createTokenContext(input.authToken) : ctx; - - return await db.project.clone(input, ctx, sourceContext); + return await projectApi.clone(input, ctx, sourceContext); }), + create: procedure .input(z.object({ title: Title })) .mutation(async ({ input, ctx }) => { - return await db.project.create(input, ctx); + return await projectApi.create(input, ctx); }), + setMarketplaceApprovalStatus: procedure .input( z.object({ @@ -54,18 +57,28 @@ export const projectRouter = router({ }) ) .mutation(async ({ input, ctx }) => { - return await db.project.setMarketplaceApprovalStatus(input, ctx); + return await projectApi.setMarketplaceApprovalStatus(input, ctx); + }), + + updateTags: procedure + .input( + z.object({ + projectId: z.string(), + tags: z.array(z.string()), + }) + ) + .mutation(async ({ input, ctx }) => { + await projectApi.updateProjectTags(input, ctx); }), + findCurrentUserProjectIds: procedure.query(async ({ ctx }) => { if (ctx.authorization.type !== "user") { return []; } - - const projectIds = await db.project.findProjectIdsByUserId( + const projectIds = await projectApi.findProjectIdsByUserId( ctx.authorization.userId, ctx ); - return projectIds.map((project) => project.id); }), From 13b5e1c2b0a0415de5079b6db9f881faba62bea8 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Mon, 15 Sep 2025 16:08:15 +0300 Subject: [PATCH 2/2] ::migrate::