diff --git a/apps/researcher/src/app/[locale]/layout.tsx b/apps/researcher/src/app/[locale]/layout.tsx index cd97f60d2..06015648c 100644 --- a/apps/researcher/src/app/[locale]/layout.tsx +++ b/apps/researcher/src/app/[locale]/layout.tsx @@ -43,7 +43,7 @@ export default async function RootLayout({children, params: {locale}}: Props) { -
+
diff --git a/apps/researcher/src/app/[locale]/objects/[id]/object-lists-actions.ts b/apps/researcher/src/app/[locale]/objects/[id]/object-lists-actions.ts new file mode 100644 index 000000000..38a7119c9 --- /dev/null +++ b/apps/researcher/src/app/[locale]/objects/[id]/object-lists-actions.ts @@ -0,0 +1,35 @@ +'use server'; + +import {getCommunityBySlug} from '@/lib/community'; +import {objectList} from '@colonial-collections/database'; +import {ObjectItemBeingCreated} from '@colonial-collections/database'; +import {revalidatePath} from 'next/cache'; + +export async function getCommunityLists(communityId: string, objectId: string) { + return objectList.getByCommunityId(communityId, {objectIri: objectId}); +} + +interface AddObjectToListProps { + objectItem: ObjectItemBeingCreated; + communityId: string; +} + +export async function addObjectToList({ + objectItem, + communityId, +}: AddObjectToListProps) { + const addObjectPromise = objectList.addObject(objectItem); + const getCommunityPromise = getCommunityBySlug(communityId); + const [community] = await Promise.all([ + getCommunityPromise, + addObjectPromise, + ]); + + revalidatePath(`/[locale]/communities/${community.slug}`, 'page'); +} + +export async function removeObjectFromList(id: number, communityId: string) { + await objectList.removeObject(id); + const community = await getCommunityBySlug(communityId); + revalidatePath(`/[locale]/communities/${community.slug}`, 'page'); +} diff --git a/apps/researcher/src/app/[locale]/objects/[id]/object-lists-menu.tsx b/apps/researcher/src/app/[locale]/objects/[id]/object-lists-menu.tsx new file mode 100644 index 000000000..aa89ee64a --- /dev/null +++ b/apps/researcher/src/app/[locale]/objects/[id]/object-lists-menu.tsx @@ -0,0 +1,170 @@ +'use client'; + +import {useTranslations} from 'next-intl'; +import {Fragment, useState, useEffect} from 'react'; +import {Menu, Transition} from '@headlessui/react'; +import {ChevronDownIcon} from '@heroicons/react/20/solid'; +import {CheckIcon} from '@heroicons/react/24/outline'; +import {useUser} from '@clerk/nextjs'; +import { + getCommunityLists, + addObjectToList, + removeObjectFromList, +} from './object-lists-actions'; +import {ObjectList} from '@colonial-collections/database'; +import {useNotifications} from 'ui'; + +interface CommunityMenuItemsProps { + communityId: string; + objectId: string; + userId: string; +} + +function CommunityMenuItems({ + communityId, + objectId, + userId, +}: CommunityMenuItemsProps) { + const [objectLists, setObjectLists] = useState([]); + const t = useTranslations('ObjectDetails'); + const {addNotification} = useNotifications(); + + async function listClick(objectList: ObjectList) { + const isEmptyList = !objectList.objects!.length; + + if (isEmptyList) { + try { + await addObjectToList({ + objectItem: { + objectIri: objectId, + objectListId: objectList.id, + createdBy: userId, + }, + communityId, + }); + + addNotification({ + id: 'objectAddedToList', + message: t.rich('objectAddedToList', { + name: () => {objectList.name}, + }), + type: 'success', + }); + } catch (err) { + addNotification({ + id: 'errorObjectAddedToList', + message: t('errorObjectAddedToList'), + type: 'error', + }); + } + } else { + try { + await removeObjectFromList(objectList.objects![0].id, communityId); + + addNotification({ + id: 'objectRemovedFromList', + message: t.rich('objectRemovedFromList', { + name: () => {objectList.name}, + }), + type: 'success', + }); + } catch (err) { + addNotification({ + id: 'errorObjectRemovedFromList', + message: t('errorObjectRemovedFromList'), + type: 'error', + }); + } + } + } + + useEffect(() => { + async function getLists() { + const lists = await getCommunityLists(communityId, objectId); + setObjectLists(lists); + } + getLists(); + }, [communityId, objectId]); + + if (!objectLists.length) { + return ( +
+ {t('noListsInCommunity')} +
+ ); + } + + return ( +
+ {objectLists.map(objectList => ( + + + + ))} +
+ ); +} + +interface ObjectListsMenuProps { + objectId: string; +} + +export default function ObjectListsMenu({objectId}: ObjectListsMenuProps) { + const t = useTranslations('ObjectDetails'); + const {user} = useUser(); + + if (!user || !user.organizationMemberships.length) { + return null; + } + + const communities = user.organizationMemberships.map( + membership => membership.organization + ); + + return ( + +
+ + {t('addToListButton')} + +
+ + + + {communities.map(community => ( +
+
{community.name}
+ +
+ ))} +
+
+
+ ); +} diff --git a/apps/researcher/src/app/[locale]/objects/[id]/page.tsx b/apps/researcher/src/app/[locale]/objects/[id]/page.tsx index a45ae25e5..9341386fb 100644 --- a/apps/researcher/src/app/[locale]/objects/[id]/page.tsx +++ b/apps/researcher/src/app/[locale]/objects/[id]/page.tsx @@ -18,6 +18,9 @@ import { import useCurrentPublisher from './useCurrentPublisher'; import {env} from 'node:process'; import {formatDateCreated} from './format-date-created'; +import ObjectListsMenu from './object-lists-menu'; +import {SignedIn} from '@clerk/nextjs'; +import {Notifications} from 'ui'; // Revalidate the page export const revalidate = 0; @@ -106,16 +109,9 @@ export default async function Details({params}: Props) {
- - - + + +
@@ -127,6 +123,8 @@ export default async function Details({params}: Props) { {object.name} + +
diff --git a/apps/researcher/src/messages/en/messages.json b/apps/researcher/src/messages/en/messages.json index 12e2f6062..7e22922bc 100644 --- a/apps/researcher/src/messages/en/messages.json +++ b/apps/researcher/src/messages/en/messages.json @@ -84,7 +84,12 @@ "currentPublisher": "Current location of object", "noOrganizationFound": "No organization found.", "dateCreated": "Date Made", - "dateCreatedSubTitle": "When was the object made? Could be an exact date or a range." + "dateCreatedSubTitle": "When was the object made? Could be an exact date or a range.", + "noListsInCommunity": "No lists", + "objectAddedToList": "Object added to list .", + "errorObjectAddedToList": "There was a problem adding the object to the list. Please try again later.", + "objectRemovedFromList": "Object removed from list .", + "errorObjectRemovedFromList": "There was a problem removing the object from the list. Please try again later." }, "PersonDetails": { "backButton": "Back to results", diff --git a/apps/researcher/src/messages/nl/messages.json b/apps/researcher/src/messages/nl/messages.json index 974792924..59978b8d7 100644 --- a/apps/researcher/src/messages/nl/messages.json +++ b/apps/researcher/src/messages/nl/messages.json @@ -84,7 +84,12 @@ "currentPublisher": "Huidige locatie van object", "noOrganizationFound": "Geen organisatie gevonden.", "dateCreated": "Datum van creatie", - "dateCreatedSubTitle": "Wanneer is het voorwerp gemaakt? Dit kan een exacte datum of een datumbereik zijn." + "dateCreatedSubTitle": "Wanneer is het voorwerp gemaakt? Dit kan een exacte datum of een datumbereik zijn.", + "noListsInCommunity": "Geen lijsten", + "objectAddedToList": "Object toegevoegd aan lijst .", + "errorObjectAddedToList": "Er is een probleem opgetreden bij het toevoegen van het object aan de lijst. Probeer het later opnieuw.", + "objectRemovedFromList": "Object verwijderd van lijst .", + "errorObjectRemovedFromList": "Er is een probleem opgetreden bij het verwijderen van het object van de lijst. Probeer het later opnieuw." }, "PersonDetails": { "backButton": "Terug naar resultaten", diff --git a/packages/database/index.ts b/packages/database/index.ts index 506cb41dc..5d7696735 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -1 +1,2 @@ export * as objectList from './src/object-list'; +export * from './src/db/types'; diff --git a/packages/database/migrations/0003_object_list_id_not_null.sql b/packages/database/migrations/0003_object_list_id_not_null.sql new file mode 100644 index 000000000..a9da86c8c --- /dev/null +++ b/packages/database/migrations/0003_object_list_id_not_null.sql @@ -0,0 +1 @@ +ALTER TABLE `object_item` MODIFY COLUMN `object_list_id` int NOT NULL; \ No newline at end of file diff --git a/packages/database/migrations/0004_object_item_change_primary_key.sql b/packages/database/migrations/0004_object_item_change_primary_key.sql new file mode 100644 index 000000000..64f0bfced --- /dev/null +++ b/packages/database/migrations/0004_object_item_change_primary_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE `object_item` ADD `id` serial AUTO_INCREMENT NOT NULL;--> statement-breakpoint +ALTER TABLE `object_item` DROP PRIMARY KEY;--> statement-breakpoint +ALTER TABLE `object_item` ADD PRIMARY KEY(`id`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0003_snapshot.json b/packages/database/migrations/meta/0003_snapshot.json new file mode 100644 index 000000000..1ce531d56 --- /dev/null +++ b/packages/database/migrations/meta/0003_snapshot.json @@ -0,0 +1,165 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "6dcd4a54-7745-42cd-b50f-37cf73c96c19", + "prevId": "f2311843-75be-4ca1-be64-e5620a7b268b", + "tables": { + "object_item": { + "name": "object_item", + "columns": { + "object_id": { + "name": "object_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_iri": { + "name": "object_iri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_list_id": { + "name": "object_list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_by": { + "name": "created_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id": { + "name": "id", + "columns": [ + "object_id" + ], + "isUnique": false + }, + "object_list_id": { + "name": "object_list_id", + "columns": [ + "object_list_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "object_item_object_id": { + "name": "object_item_object_id", + "columns": [ + "object_id" + ] + } + }, + "uniqueConstraints": { + "object_item_object_id_object_list_id_unique": { + "name": "object_item_object_id_object_list_id_unique", + "columns": [ + "object_id", + "object_list_id" + ] + } + } + }, + "object_list": { + "name": "object_list", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "community_id": { + "name": "community_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_by": { + "name": "created_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "community_id": { + "name": "community_id", + "columns": [ + "community_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "object_list_id": { + "name": "object_list_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/0004_snapshot.json b/packages/database/migrations/meta/0004_snapshot.json new file mode 100644 index 000000000..484fb19db --- /dev/null +++ b/packages/database/migrations/meta/0004_snapshot.json @@ -0,0 +1,172 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ca3beccc-2f44-41c8-b5bd-4a580d9afcf2", + "prevId": "6dcd4a54-7745-42cd-b50f-37cf73c96c19", + "tables": { + "object_item": { + "name": "object_item", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "object_id": { + "name": "object_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_iri": { + "name": "object_iri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_list_id": { + "name": "object_list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_by": { + "name": "created_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id": { + "name": "id", + "columns": [ + "object_id" + ], + "isUnique": false + }, + "object_list_id": { + "name": "object_list_id", + "columns": [ + "object_list_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "object_item_id": { + "name": "object_item_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "object_item_object_id_object_list_id_unique": { + "name": "object_item_object_id_object_list_id_unique", + "columns": [ + "object_id", + "object_list_id" + ] + } + } + }, + "object_list": { + "name": "object_list", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "community_id": { + "name": "community_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_by": { + "name": "created_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "community_id": { + "name": "community_id", + "columns": [ + "community_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "object_list_id": { + "name": "object_list_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 58d559d8d..2e513c668 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -22,6 +22,20 @@ "when": 1695393273717, "tag": "0002_object_list_name_not_null", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1695717398044, + "tag": "0003_object_list_id_not_null", + "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1696425284032, + "tag": "0004_object_item_change_primary_key", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/db/schema.ts b/packages/database/src/db/schema.ts index d0f858dd4..1cf2c4b2d 100644 --- a/packages/database/src/db/schema.ts +++ b/packages/database/src/db/schema.ts @@ -38,9 +38,10 @@ export const objectListsRelations = relations(objectLists, ({many}) => ({ export const objectItems = mysqlTable( 'object_item', { - objectId: varchar('object_id', {length: 32}).primaryKey(), + id: serial('id').primaryKey(), + objectId: varchar('object_id', {length: 32}).notNull(), objectIri: text('object_iri').notNull(), - objectListId: int('object_list_id'), + objectListId: int('object_list_id').notNull(), createdAt: timestamp('created_at') .default(sql`CURRENT_TIMESTAMP`) .notNull(), @@ -50,7 +51,7 @@ export const objectItems = mysqlTable( createdBy: varchar('created_by', {length: 50}).notNull(), }, item => ({ - id: index('id').on(item.objectId), + objectId: index('id').on(item.objectId), objectListId: index('object_list_id').on(item.objectListId), unique: unique().on(item.objectId, item.objectListId), }) diff --git a/packages/database/src/db/types.ts b/packages/database/src/db/types.ts new file mode 100644 index 000000000..c7e1bfb2a --- /dev/null +++ b/packages/database/src/db/types.ts @@ -0,0 +1,13 @@ +import {InferSelectModel, InferInsertModel} from 'drizzle-orm'; +import {objectLists, objectItems} from './schema'; + +export interface ObjectList extends InferSelectModel { + objects?: InferSelectModel[]; +} +export type ObjectListsBeingCreated = InferInsertModel; + +export type ObjectItem = InferSelectModel; +export type ObjectItemBeingCreated = Omit< + InferInsertModel, + 'objectId' +>; diff --git a/packages/database/src/object-list.ts b/packages/database/src/object-list.ts index bc6639312..1d8e115cb 100644 --- a/packages/database/src/object-list.ts +++ b/packages/database/src/object-list.ts @@ -1,22 +1,21 @@ import {objectLists, objectItems} from './db/schema'; import {insertObjectItemSchema, insertObjectListSchema} from './db/validation'; -import {InferSelectModel, sql} from 'drizzle-orm'; +import {sql} from 'drizzle-orm'; import {db} from './db/connection'; import {iriToHash} from './iri-to-hash'; import {DBQueryConfig, eq} from 'drizzle-orm'; +import {ObjectList} from './db/types'; interface Option { withObjects?: boolean; limitObjects?: number; -} - -interface ObjectList extends InferSelectModel { - objects?: InferSelectModel[]; + objectIri?: string; } export async function getByCommunityId( communityId: string, - {withObjects, limitObjects}: Option = {withObjects: false} + {withObjects, limitObjects, objectIri}: Option = {withObjects: false} + // Explicitly set the return type, or else `objects` will not be included. ): Promise { const options: DBQueryConfig = {}; @@ -26,6 +25,15 @@ export async function getByCommunityId( }; } + if (objectIri) { + const objectId = iriToHash(objectIri); + options.with = { + objects: { + where: (objectItems, {eq}) => eq(objectItems.objectId, objectId), + }, + }; + } + return db.query.objectLists.findMany({ ...options, where: (objectLists, {eq}) => eq(objectLists.communityId, communityId), @@ -66,18 +74,26 @@ export async function create({ } interface AddObjectProps { - listId: number; + objectListId: number; objectIri: string; - userId: string; + createdBy: string; } -export async function addObject({listId, objectIri, userId}: AddObjectProps) { +export async function addObject({ + objectListId, + objectIri, + createdBy, +}: AddObjectProps) { const objectItem = insertObjectItemSchema.parse({ - listId, + objectListId, objectIri, - createdBy: userId, + createdBy, objectId: iriToHash(objectIri), }); return db.insert(objectItems).values(objectItem); } + +export async function removeObject(id: number) { + return db.delete(objectItems).where(eq(objectItems.id, id)); +} diff --git a/packages/ui/notifications.tsx b/packages/ui/notifications.tsx index dcf00e0dc..e6d2ce240 100644 --- a/packages/ui/notifications.tsx +++ b/packages/ui/notifications.tsx @@ -5,10 +5,16 @@ import {XMarkIcon} from '@heroicons/react/24/outline'; import {ReactNode, useEffect} from 'react'; import {usePathname} from 'next/navigation'; +const typeColors = { + success: 'greenGrey', + warning: 'yellow', + error: 'red', +}; + type Notification = { id: string; message: ReactNode; - type: 'success'; // For now we only have success notifications, but we could add more types later + type: 'success' | 'warning' | 'error'; }; interface State { @@ -49,20 +55,23 @@ export function Notifications() { return (
- {notifications.map(notification => ( -
-
{notification.message}
- -
- ))} +
{notification.message}
+ +
+ ); + })}
); }