diff --git a/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx b/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx index a05e3d3b..21dc095d 100644 --- a/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx +++ b/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx @@ -16,6 +16,7 @@ import BreadCrumbs from '@/components/BreadCrumbs'; import { Icons } from '@/components/icons'; import JsonLd from '@/components/JsonLd'; import { Loading } from '@/components/loading'; +import { getUseCaseSummaryExcerpt } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/content-document'; import { stripMarkdown } from '../../search/components/UnifiedListingComponent'; import PrimaryDetails from '../components/Details'; import Metadata from '../components/Metadata'; @@ -518,7 +519,7 @@ const CollaborativeDetailClient = () => { const commonProps = { title: useCase.title || '', - description: stripMarkdown(useCase.summary || ''), + description: getUseCaseSummaryExcerpt(useCase.summary), metadataContent: MetadataContent, tag: useCase.tags?.map((t) => t.value) || [], leftFooterChips: LeftFooterChips, diff --git a/app/[locale]/(user)/components/Content.tsx b/app/[locale]/(user)/components/Content.tsx index 53c3f190..ec547213 100644 --- a/app/[locale]/(user)/components/Content.tsx +++ b/app/[locale]/(user)/components/Content.tsx @@ -121,7 +121,7 @@ export const Content = () => { {item.label} diff --git a/app/[locale]/(user)/components/Sectors.tsx b/app/[locale]/(user)/components/Sectors.tsx index b097ef8c..86e13049 100644 --- a/app/[locale]/(user)/components/Sectors.tsx +++ b/app/[locale]/(user)/components/Sectors.tsx @@ -8,8 +8,8 @@ import { Button, Text } from 'opub-ui'; import { GraphQL } from '@/lib/api'; import { buildSectorSlugParam } from '@/lib/utils'; -import { SectorListingSkeleton } from '@/components/loading'; import { Icons } from '@/components/icons'; +import { SectorListingSkeleton } from '@/components/loading'; import { SectorCard } from '@/components/SectorCard'; const sectorDetails = graphql(` @@ -32,7 +32,7 @@ const Sectors = () => { const router = useRouter(); return ( -
+
Explore Sectors @@ -44,7 +44,7 @@ const Sectors = () => {
-
+ const sheetSource = versions.find((version) => version.id === sheetVersionId); + const sheetVersion = sheetSource ? toVersionRow(sheetSource) : null; + const onlyVersion = versions.length === 1; - {versions.length > 0 ? ( - (() => { - const currentVersion = selectedVersion || latestVersion; - if (!currentVersion) return null; - - return ( -
-
- - Lifecycle Stage * - - - setNewVersionData((prev) => ({ - ...prev, - lifecycleStage: ( - Object.values(AiModelLifecycleStage) as string[] - ).includes(value) - ? (value as AiModelLifecycleStage) - : prev.lifecycleStage, - })) - } - required - requiredIndicator={true} - /> - {!newVersionData.lifecycleStage && ( - - Lifecycle Stage is required + +
+
+
+ + {sheetMode === 'add' ? 'Add Version' : 'Edit Version'} - )} - option.value !== '' + )} + value={sheetLifecycle || 'DEVELOPMENT'} + onChange={setSheetLifecycle} + /> +
+
+ setSheetPrimary((current) => !current)} + > + Set as Primary Version + + + {soleVersion + ? 'The only version is automatically the Primary version.' + : otherPrimary + ? `Only one version can be Primary — this will replace "Version ${otherPrimary.version}" as the Primary version.` + : 'Only one version can be Primary — this will replace the current Primary version.'}
- - -
-
- - - )} - - - {/* Provider Modal */} - - {isProviderModalOpen && ( - - -
- - setProviderFormData((prev) => ({ - ...prev, - apiAuthType: ( - Object.values(EndpointAuthType) as string[] - ).includes(value) - ? (value as EndpointAuthType) - : prev.apiAuthType, - })) - } - /> - - setProviderFormData((prev) => ({ - ...prev, - apiAuthHeaderName: value, - })) - } - placeholder="Authorization" - helpText="Header name for authentication (e.g., Authorization, X-API-Key)" - /> - - setProviderFormData((prev) => ({ - ...prev, - apiRequestTemplate: value, - })) - } - placeholder='{"model": "{model_id}", - "messages": [{"role": "user", "content": "{input}"}] - "temperature": {temperature}, - "max_tokens": {max_tokens} - }' - helpText="Request body template with placeholders like {input}, {prompt}, {model_id}, {temperature}, {max_tokens}" - /> - - setProviderFormData((prev) => ({ - ...prev, - apiResponsePath: value, - })) - } - placeholder="choices[0].message.content" - helpText="JSON path to extract response text" - /> - - setProviderFormData((prev) => ({ - ...prev, - apiTimeoutSeconds: parseInt(value) || 60, - })) - } - helpText="Request timeout in seconds" - /> - - )} +
+ Access Methods + + Configure how this version can be accessed. You can save this + version even if access methods are incomplete or missing — + they're only required when you publish the AI Model. + - {/* Huggingface-specific fields */} - {providerFormData.provider === 'HUGGINGFACE' && ( - <> - - setProviderFormData((prev) => ({ - ...prev, - hfAuthToken: value, - })) - } - helpText="Required for gated models" - /> - - setProviderFormData((prev) => ({ - ...prev, - framework: value, - })) - } - /> - - setProviderFormData((prev) => ({ - ...prev, - hfAttnImplementation: value, - })) - } - helpText="e.g., flash_attention_2, eager, sdpa" - /> - - setProviderFormData((prev) => ({ - ...prev, - hfUsePipeline: !prev.hfUsePipeline, - })) - } + {sheetProviders.length === 0 && !isProviderModalOpen ? ( +
+ + No access methods configured + +
+ + Add one so this version can be reached by consumers. + +
+ +
+ ) : null} + + {sheetProviders.map((provider, index) => + isProviderModalOpen && + editingProvider?.id === provider.id ? null : ( +
- Use Pipeline API - - +
+ + {accessMethodName(provider.config) || + getProviderDisplayName(provider.provider)} + {provider.providerModelId + ? ` · ${provider.providerModelId}` + : ''} + +
+ + {getEndpointUrl(provider)} + + {provider.isPrimary ? Primary : null} +
+
+
+ } + disabled={isProviderModalOpen || !sheetVersion} + onClick={() => + sheetVersion && + handleOpenProviderModal(sheetVersion, provider) + } + > + Edit access method {index + 1} + + } + + disabled={isProviderModalOpen} + onClick={() => + setAccessMethodToDelete({ + id: provider.id, + name: + accessMethodName(provider.config) || + getProviderDisplayName(provider.provider), + }) + } + > + Remove access method {index + 1} + +
+
+ ) )} - - setProviderFormData((prev) => ({ - ...prev, - isPrimary: !prev.isPrimary, - })) - } - > - Set as Primary Provider - + {isProviderModalOpen ? ( +
+
+ + {editingProvider + ? 'Editing access method' + : 'New Access Method'} + +
+ +
+ + setProviderFormData((prev) => ({ + ...prev, + accessName: value, + })) + } + /> + + setProviderFormData((prev) => { + const nextAuth = ( + Object.values(EndpointAuthType) as string[] + ).includes(value) + ? (value as EndpointAuthType) + : prev.apiAuthType; + return { + ...prev, + apiAuthType: nextAuth, + apiAuthHeaderName: + nextAuth === 'BEARER' + ? 'Authorization' + : '', + apiKeyLocation: 'header', + apiKey: '', + apiKeyPrefix: + nextAuth === 'BEARER' ? 'Bearer' : '', + }; + }) + } + /> + + {providerFormData.apiAuthType === 'BEARER' && ( +
+ + setProviderFormData((prev) => ({ + ...prev, + apiAuthHeaderName: value, + })) + } + placeholder="Authorization" + required + requiredIndicator={true} + /> + + setProviderFormData((prev) => ({ + ...prev, + apiKey: value, + })) + } + placeholder="Enter bearer token" + required + requiredIndicator={true} + /> +
+ )} + + {providerFormData.apiAuthType === 'API_KEY' && ( +
+ + setProviderFormData((prev) => ({ + ...prev, + apiAuthHeaderName: value, + })) + } + placeholder="e.g. X-API-Key" + required + requiredIndicator={true} + /> + + setProviderFormData((prev) => ({ + ...prev, + hfModelClass: value, + })) + } + required + requiredIndicator={true} + /> + { + const file = event.target.files?.[0]; + if (file) onSelect(file); + event.target.value = ''; + }} + /> + {preview ? ( +
+
+ {label} +
+ + {image ? imageName(image) : label} + + {knownBytes ? ( + + {formatFileSize(knownBytes)} + + ) : null} +
+
+ + +
+
+ + {hint} + +
+ ) : ( + { + if (accepted[0]) onSelect(accepted[0]); + }} + > + + + )} +
+
+ ); +} + +export default function AboutPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + const queryClient = useQueryClient(); + const { + setStatus, + setAboutCompleted, + setStepShowErrors, + registerBeforeNavigateHandler, + stepShowErrors, + } = useCollaborativeEditStatus(); + const ownerArgs = { [params.entityType]: params.entitySlug }; + + const basicQuery = useQuery( + [`fetch_collaborative_about_${params.id}`], + () => + GraphQL(FetchCollaborative, ownerArgs, { + filters: { id: params.id }, + }), + { refetchOnMount: true } + ); + const metadataQuery = useQuery( + [`fetch_collaborative_about_metadata_${params.id}`], + () => + GraphQL(FetchCollaborativeMetadata, ownerArgs, { + filters: { id: params.id }, + }), + { refetchOnMount: true } + ); + const sectorsQuery = useQuery([`collaborative_wizard_sectors`], () => + GraphQL(sectorsListQueryDoc, ownerArgs) + ); + const tagsQuery = useQuery([`collaborative_wizard_tags`], () => + GraphQL(tagsListQueryDoc, ownerArgs) + ); + const sdgsQuery = useQuery([`collaborative_wizard_sdgs`], () => + GraphQL(sdgsListQueryDoc, ownerArgs) + ); + const geosQuery = useQuery([`collaborative_wizard_geos`], () => + GraphQL(geographiesListQueryDoc, ownerArgs) + ); + + const basic = basicQuery.data?.collaboratives?.[0]; + const classified = metadataQuery.data?.collaboratives?.[0]; + + const [form, setForm] = useState({ + title: '', + summary: '', + platformUrl: '', + slug: '', + sectors: [], + sdgs: [], + tags: [], + geographies: [], + }); + const formRef = useRef(form); + useEffect(() => { + formRef.current = form; + }); + const [subdomainError, setSubdomainError] = useState(); + const [logo, setLogo] = useState(null); + const [coverImage, setCoverImage] = useState( + null + ); + + const hydratedBasic = useRef(false); + const hydratedClass = useRef(false); + + useEffect(() => { + if (!basic || hydratedBasic.current) return; + hydratedBasic.current = true; + setForm((prev) => ({ + ...prev, + title: basic.title ?? '', + summary: basic.summary ?? '', + platformUrl: basic.platformUrl ?? '', + slug: basic.slug ?? '', + })); + setLogo(basic.logo ?? null); + setCoverImage(basic.coverImage ?? null); + }, [basic]); + + useEffect(() => { + if (!classified || hydratedClass.current) return; + hydratedClass.current = true; + setForm((prev) => ({ + ...prev, + sectors: + classified.sectors?.map((item) => ({ + label: item.name ?? '', + value: item.id, + })) ?? [], + sdgs: + classified.sdgs?.map((item) => ({ + label: sdgLabel(item), + value: item.id, + })) ?? [], + tags: + classified.tags?.map((item) => ({ + label: item.value ?? '', + value: item.id, + })) ?? [], + geographies: + classified.geographies?.map((item) => ({ + label: item.name ?? '', + value: item.id, + })) ?? [], + })); + }, [classified]); + + useEffect(() => { + setAboutCompleted(isCollaborativeAboutComplete(form)); + }, [form, setAboutCompleted]); + + const { mutateAsync: saveCollaborative, isLoading: savingCollaborative } = + useMutation( + (data: { data: CollaborativeInputPartial }) => + GraphQL(UpdateCollaborativeMutation, ownerArgs, data), + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [`collaborative_wizard_${params.id}`], + }); + void basicQuery.refetch(); + }, + onError: (error: unknown) => { + toast(errorMessage(error, 'Unable to save this collaborative.')); + }, + } + ); + + const { mutate: saveMetadata, isLoading: savingMetadata } = useMutation( + (input: UpdateCollaborativeMetadataInput) => + GraphQL(UpdateCollaborativeMetadata, ownerArgs, { + updateMetadataInput: input, + }), + { + onSuccess: () => { + void metadataQuery.refetch(); + void tagsQuery.refetch(); + void queryClient.invalidateQueries({ + queryKey: [`collaborative_wizard_${params.id}`], + }); + }, + onError: (error: unknown) => { + toast(errorMessage(error, 'Unable to save classification right now.')); + }, + } + ); + + const persistAbout = useCallback( + async (next = formRef.current) => { + const slug = slugify(next.slug) || slugify(next.title); + if (slug && isReservedCollaborativeSubdomain(slug)) { + setSubdomainError('This subdomain is reserved. Choose another.'); + await saveCollaborative({ + data: { + id: params.id, + title: next.title, + summary: next.summary, + platformUrl: next.platformUrl, + }, + }); + return; + } + + setSubdomainError(undefined); + const resolved = slug === next.slug ? next : { ...next, slug }; + if (resolved !== next) { + formRef.current = resolved; + setForm(resolved); + } + + await saveCollaborative({ + data: { + id: params.id, + title: resolved.title, + summary: resolved.summary, + platformUrl: resolved.platformUrl, + ...(slug ? { slug } : {}), + }, + }); + }, + [params.id, saveCollaborative] + ); + + const persistMetadata = useCallback( + (next = formRef.current) => { + saveMetadata({ + id: params.id, + metadata: + classified?.metadata?.map((item) => ({ + id: item.metadataItem.id, + value: item.value ?? '', + })) ?? [], + sectors: comboValues(next.sectors, 'value'), + tags: comboValues(next.tags, 'label'), + sdgs: comboValues(next.sdgs, 'value'), + geographies: comboValues(next.geographies, 'value').map((value) => + parseInt(value, 10) + ), + }); + }, + [classified?.metadata, params.id, saveMetadata] + ); + + const saveImage = (field: 'logo' | 'coverImage', file: File | null) => { + if (file && file.size > MAX_IMAGE_BYTES) { + toast('Image must be 20MB or smaller.'); + return; + } + if (field === 'logo') setLogo(file); + else setCoverImage(file); + void saveCollaborative({ + data: { + id: params.id, + [field]: file, + }, + }) + .then((res) => { + const next = res.updateCollaborative; + if (!next || !('logo' in next)) return; + if (field === 'logo') setLogo(next.logo ?? null); + else setCoverImage(next.coverImage ?? null); + }) + .catch(() => {}); + }; + + useEffect(() => { + registerBeforeNavigateHandler(() => persistAbout()); + return () => registerBeforeNavigateHandler(null); + }, [persistAbout, registerBeforeNavigateHandler]); + + useEffect(() => { + setStatus(savingCollaborative || savingMetadata ? 'loading' : 'success'); + }, [savingCollaborative, savingMetadata, setStatus]); + + const formReady = + !basicQuery.isLoading && + !metadataQuery.isLoading && + !sectorsQuery.isLoading && + !tagsQuery.isLoading && + !sdgsQuery.isLoading && + !geosQuery.isLoading; + + useEffect(() => { + if (!formReady) return; + const hash = window.location.hash.replace('#', ''); + if (!hash) return; + setStepShowErrors(true); + const frame = window.requestAnimationFrame(() => { + document.getElementById(hash)?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }); + return () => window.cancelAnimationFrame(frame); + }, [formReady, setStepShowErrors]); + + if (!formReady) { + return ( +
+ +
+ ); + } + + const titleError = + stepShowErrors && !form.title.trim() + ? 'Enter a Collaborative name' + : undefined; + const subdomainPreview = form.slug.trim() || slugify(form.title); + const subdomainHelp = subdomainPreview + ? `Public address: ${getCollaborativeDetailUrl(subdomainPreview)}` + : 'Letters, numbers, and hyphens. This becomes the public address for this Collaborative.'; + const summaryError = + stepShowErrors && !plainSummary(form.summary) + ? 'Add a description to continue' + : undefined; + const sectorError = + stepShowErrors && form.sectors.length === 0 + ? 'Select at least one sector' + : undefined; + const sdgError = + stepShowErrors && form.sdgs.length === 0 + ? 'Select at least one SDG goal' + : undefined; + + const handleClassChange = (field: keyof AboutForm, value: unknown) => { + const next = { ...formRef.current, [field]: asOptions(value) }; + setForm(next); + persistMetadata(next); + }; + + return ( +
+ +
+ saveImage('coverImage', file)} + onRemove={() => saveImage('coverImage', null)} + /> + saveImage('logo', file)} + onRemove={() => saveImage('logo', null)} + /> + +
+ + setForm((prev) => ({ ...prev, title: value })) + } + onBlur={() => persistAbout()} + /> +
+
+ { + setSubdomainError(undefined); + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+/, ''); + const next = { ...formRef.current, slug }; + formRef.current = next; + setForm(next); + }} + onBlur={() => persistAbout()} + /> +
+
+ + setForm((prev) => ({ ...prev, summary: value })) + } + onBlur={(value) => { + const next = { ...formRef.current, summary: value }; + setForm(next); + persistAbout(next); + }} + /> +
+ + setForm((prev) => ({ ...prev, platformUrl: value })) + } + onBlur={() => persistAbout()} + /> +
+
+ + +
+
+ ({ + label: item.name, + value: item.id, + })) || [] + } + selectedValue={form.sectors} + onChange={(value) => handleClassChange('sectors', value)} + /> +
+
+ ({ + label: sdgLabel(item), + value: item.id, + })) || [] + } + selectedValue={form.sdgs} + onChange={(value) => handleClassChange('sdgs', value)} + /> +
+ ({ + label: item.value ?? '', + value: item.id, + })) || [] + } + selectedValue={form.tags} + onChange={(value) => handleClassChange('tags', value)} + /> + ({ + label: `${item.name}${item.parentId ? ` (${item.parentId.name})` : ''}`, + value: item.id, + })) || [] + } + selectedValue={form.geographies} + onChange={(value) => handleClassChange('geographies', value)} + /> +
+
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/assign/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/assign/page.tsx index 5908b657..25ff3c4b 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/assign/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/assign/page.tsx @@ -1,215 +1,7 @@ 'use client'; -import React, { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { fetchDatasets } from '@/fetch'; -import { graphql } from '@/gql'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, DataTable, Text, toast } from 'opub-ui'; +import { RedirectToCollaborativeStep } from '../../RedirectToCollaborativeStep'; -import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import { Loading } from '@/components/loading'; - -interface SearchDataset { - id: string; - title: string; - modified?: string; - sectors?: Array<{ name?: string }>; -} - -interface AssignTableRow { - id: string; - title: string; - category: string | { name?: string } | undefined; - modified: string; +export default function AssignRedirect() { + return ; } - -// prettier-ignore -const FetchCollaborativeDetails = graphql(` - query CollaborativeDetails($filters: CollaborativeFilter) { - collaboratives(filters: $filters) { - id - title - datasets { - id - title - modified - sectors { - name - } - } - } - } -`); - -// prettier-ignore -const AssignCollaborativeDatasets = graphql(` - mutation assignCollaborativeDatasets($collaborativeId: String!, $datasetIds: [UUID!]!) { - updateCollaborativeDatasets(collaborativeId: $collaborativeId, datasetIds: $datasetIds) { - ... on TypeCollaborative { - id - datasets { - id - title - } - } - } - } -`); -const Assign = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const router = useRouter(); - const queryClient = useQueryClient(); - const COLLAB_ASSIGN_TOAST_ID = 'collaboratives-assign-toast'; - - const [data, setData] = useState([]); - const [selectedRow, setSelectedRows] = useState([]); - - const CollaborativeDetails = - useQuery( - [`Collaborative_Details`, params.id], - () => - GraphQL( - FetchCollaborativeDetails, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const formattedData = ( - datasets: Array<{ - id: string; - title?: string | null; - modified?: string | null; - sectors?: Array<{ name?: string | null } | null> | null; - }> - ) => - datasets.map((item) => { - return { - title: item.title, - id: item.id, - category: item.sectors?.[0]?.name || 'N/A', // Safeguard in case of missing category - modified: formatDate(item.modified ?? null) || '', - }; - }); - - useEffect(() => { - fetchDatasets('?size=1000&page=1') - .then((res: { results: SearchDataset[] }) => { - setData(res.results); - }) - .catch((err) => { - console.error(err); - }); - }, []); - - const columns = [ - { accessorKey: 'title', header: 'Title' }, - { accessorKey: 'category', header: 'Sector' }, - { accessorKey: 'modified', header: 'Last Modified' }, - ]; - - const generateTableData = (list: SearchDataset[]) => { - return list.map((item) => { - return { - title: item.title, - id: item.id, - category: item.sectors?.[0], - modified: formatDate(item.modified ?? null) || '', - }; - }); - }; - - const { mutate } = useMutation( - () => - GraphQL( - AssignCollaborativeDatasets, - { - [params.entityType]: params.entitySlug, - }, - { - collaborativeId: params.id, - datasetIds: Array.isArray(selectedRow) - ? selectedRow.map((row) => row.id) - : [], - } - ), - { - onSuccess: () => { - toast('Dataset Assigned Successfully', { id: COLLAB_ASSIGN_TOAST_ID }); - queryClient.invalidateQueries({ - queryKey: [`Collaborative_Details`, params.id], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - router.push( - `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/usecases` - ); - }, - onError: (err: unknown) => { - toast(`Received ${err} on dataset publish `, { - id: COLLAB_ASSIGN_TOAST_ID, - }); - }, - } - ); - - return ( - <> - {((CollaborativeDetails?.data?.collaboratives?.[0]?.datasets?.length ?? -1) >= 0) && - data.length > 0 && - !CollaborativeDetails.isLoading ? ( - <> -
-
- - Selected {selectedRow.length} of {data.length} - -
-
- -
-
- - { - setSelectedRows(Array.isArray(selected) ? selected : []); // Ensure selected is always an array - }} - /> - - ) : ( - - )} - - ); -}; - -export default Assign; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/content/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/content/page.tsx new file mode 100644 index 00000000..b2c1c095 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/content/page.tsx @@ -0,0 +1,441 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { fetchData, fetchDatasets } from '@/fetch'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { SectionCard, Spinner, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { AddDatasetSheet } from '../../../../usecases/edit/components/AddDatasetSheet'; +import { + EntityRowPicker, + type EntityRow, + type EntityRowPickerHandle, +} from '../../../../usecases/edit/components/EntityRowPicker'; +import { AddUseCase } from '../../../../usecases/page'; +import { getUseCaseSummaryExcerpt } from '../../../../usecases/edit/content-document'; +import { errorMessage } from '../../collaborative-summary'; +import { useCollaborativeEditStatus } from '../../context'; +import styles from '../../edit.module.scss'; +import { + AssignCollaborativeDatasets, + AssignCollaborativeUseCases, + FetchCollaborativeReview, + sectorsListQueryDoc, +} from '../../wizard-documents'; + +interface SearchDataset { + id: string; + title: string; + description?: string; + status?: string; + sectors?: Array; + geographies?: Array; +} + +interface SearchUseCase { + id: string | number; + title: string; + summary?: string; + sectors?: Array; +} + +const PAGE_SIZE = 20; + +function firstLabel( + items?: Array | null +) { + const first = items?.[0]; + if (!first) return undefined; + if (typeof first === 'string') return first; + return first.name || undefined; +} + +function statusLabel(status?: string | null) { + if (status === 'DRAFT' || status === 'draft') return 'Draft'; + if (status === 'ARCHIVED' || status === 'archived') return 'Archived'; + return 'Published'; +} + +export default function ContentPage() { + const router = useRouter(); + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + const queryClient = useQueryClient(); + const { setStatus } = useCollaborativeEditStatus(); + const ownerArgs = { [params.entityType]: params.entitySlug }; + const datasetsPickerRef = useRef(null); + const useCasesPickerRef = useRef(null); + const [datasetSearch, setDatasetSearch] = useState(''); + const [debouncedDatasetSearch, setDebouncedDatasetSearch] = useState(''); + const [useCaseSearch, setUseCaseSearch] = useState(''); + const [debouncedUseCaseSearch, setDebouncedUseCaseSearch] = useState(''); + const [datasetSheetOpen, setDatasetSheetOpen] = useState(false); + const [creatingDataset, setCreatingDataset] = useState(false); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedDatasetSearch(datasetSearch.trim()); + }, 300); + return () => window.clearTimeout(timer); + }, [datasetSearch]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedUseCaseSearch(useCaseSearch.trim()); + }, 300); + return () => window.clearTimeout(timer); + }, [useCaseSearch]); + + const sectorsQuery = useQuery([`collaborative_wizard_sectors`], () => + GraphQL(sectorsListQueryDoc, ownerArgs) + ); + + const collaborativeQuery = useQuery( + [`collaborative_wizard_${params.id}`], + () => + GraphQL(FetchCollaborativeReview, ownerArgs, { + filters: { id: params.id }, + }), + { refetchOnMount: true } + ); + + const datasetsQuery = useInfiniteQuery( + [`collaborative_wizard_datasets`, debouncedDatasetSearch], + async ({ pageParam = 1 }) => { + const search = new URLSearchParams({ + size: String(PAGE_SIZE), + page: String(pageParam), + sort: 'recent', + }); + if (debouncedDatasetSearch) search.set('query', debouncedDatasetSearch); + const res = (await fetchDatasets(`?${search.toString()}`)) as { + results?: SearchDataset[]; + total?: number; + }; + return { + results: res.results ?? [], + total: Number(res.total ?? 0), + }; + }, + { + keepPreviousData: true, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.results.length < PAGE_SIZE) return undefined; + const loaded = allPages.reduce( + (count, page) => count + page.results.length, + 0 + ); + if (lastPage.total > 0 && loaded >= lastPage.total) return undefined; + return allPages.length + 1; + }, + } + ); + + const useCasesQuery = useInfiniteQuery( + [`collaborative_wizard_usecases`, debouncedUseCaseSearch], + async ({ pageParam = 1 }) => { + const search = new URLSearchParams({ + size: String(PAGE_SIZE), + page: String(pageParam), + sort: 'recent', + }); + if (debouncedUseCaseSearch) search.set('query', debouncedUseCaseSearch); + const res = (await fetchData('usecase', `?${search.toString()}`)) as { + results?: SearchUseCase[]; + total?: number; + }; + return { + results: res.results ?? [], + total: Number(res.total ?? 0), + }; + }, + { + keepPreviousData: true, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.results.length < PAGE_SIZE) return undefined; + const loaded = allPages.reduce( + (count, page) => count + page.results.length, + 0 + ); + if (lastPage.total > 0 && loaded >= lastPage.total) return undefined; + return allPages.length + 1; + }, + } + ); + + const collaborative = collaborativeQuery.data?.collaboratives?.[0]; + const datasetIds = collaborative?.datasets?.map((item) => item.id) ?? []; + const useCaseIds = + collaborative?.useCases?.map((item) => String(item.id)) ?? []; + + const refresh = () => { + void collaborativeQuery.refetch(); + void queryClient.invalidateQueries({ + queryKey: [`collaborative_wizard_${params.id}`], + }); + }; + + const { mutate: assignDatasets, isLoading: savingDatasets } = useMutation( + (nextIds: string[]) => + GraphQL(AssignCollaborativeDatasets, ownerArgs, { + collaborativeId: params.id, + datasetIds: nextIds, + }), + { + onSuccess: refresh, + onError: (error: unknown) => + toast(errorMessage(error, 'Unable to update datasets right now.')), + } + ); + + const { mutate: assignUseCases, isLoading: savingUseCases } = useMutation( + (nextIds: string[]) => + GraphQL(AssignCollaborativeUseCases, ownerArgs, { + collaborativeId: params.id, + useCaseIds: nextIds, + }), + { + onSuccess: refresh, + onError: (error: unknown) => + toast(errorMessage(error, 'Unable to update use cases right now.')), + } + ); + + const { mutate: createUseCase, isLoading: creatingUseCase } = useMutation( + () => GraphQL(AddUseCase, ownerArgs), + { + onSuccess: (data) => { + const created = data.addUseCase; + const newId = created && 'id' in created ? created.id : undefined; + if (!newId) { + toast('Unable to create a use case right now.'); + return; + } + assignUseCases([...useCaseIds, String(newId)], { + onSuccess: () => { + router.push( + `/dashboard/${params.entityType}/${params.entitySlug}/usecases/edit/${newId}/builder` + ); + }, + }); + }, + onError: (error: unknown) => + toast(errorMessage(error, 'Unable to create a use case right now.')), + } + ); + + const contentReady = !collaborativeQuery.isLoading; + + useEffect(() => { + if (!contentReady) return; + const hash = window.location.hash.replace('#', ''); + if (!hash) return; + const frame = window.requestAnimationFrame(() => { + document.getElementById(hash)?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }); + return () => window.cancelAnimationFrame(frame); + }, [contentReady]); + + useEffect(() => { + setStatus( + savingDatasets || savingUseCases || creatingDataset || creatingUseCase + ? 'loading' + : 'success' + ); + }, [ + savingDatasets, + savingUseCases, + creatingDataset, + creatingUseCase, + setStatus, + ]); + + const loadMoreDatasets = useCallback(() => { + if (!datasetsQuery.hasNextPage || datasetsQuery.isFetchingNextPage) return; + void datasetsQuery.fetchNextPage(); + }, [ + datasetsQuery.fetchNextPage, + datasetsQuery.hasNextPage, + datasetsQuery.isFetchingNextPage, + ]); + + const loadMoreUseCases = useCallback(() => { + if (!useCasesQuery.hasNextPage || useCasesQuery.isFetchingNextPage) return; + void useCasesQuery.fetchNextPage(); + }, [ + useCasesQuery.fetchNextPage, + useCasesQuery.hasNextPage, + useCasesQuery.isFetchingNextPage, + ]); + + if (collaborativeQuery.isLoading) { + return ( +
+ +
+ ); + } + + const selectedDatasets: EntityRow[] = + collaborative?.datasets?.map((item) => ({ + id: item.id, + title: item.title ?? 'Untitled dataset', + subtitle: firstLabel(item.sectors), + summary: item.description ?? undefined, + badge: statusLabel(item.status), + })) ?? []; + + const datasetOptions: EntityRow[] = + datasetsQuery.data?.pages.flatMap((page) => + page.results.map((item) => ({ + id: String(item.id), + title: item.title, + subtitle: [firstLabel(item.sectors), firstLabel(item.geographies)] + .filter(Boolean) + .join(' · '), + summary: item.description, + badge: statusLabel(item.status), + })) + ) ?? []; + + const selectedUseCases: EntityRow[] = + collaborative?.useCases?.map((item) => ({ + id: String(item.id), + title: item.title ?? 'Untitled use case', + subtitle: firstLabel(item.sectors), + badge: 'Connected', + })) ?? []; + + const useCaseOptions: EntityRow[] = + useCasesQuery.data?.pages.flatMap((page) => + page.results.map((item) => ({ + id: String(item.id), + title: item.title, + subtitle: firstLabel(item.sectors), + summary: getUseCaseSummaryExcerpt(item.summary), + })) + ) ?? []; + + return ( +
+
+ datasetsPickerRef.current?.focus(), + }, + { + kind: 'neutral', + content: '+ Create New Dataset', + onAction: () => setDatasetSheetOpen(true), + }, + ]} + > + assignDatasets([...datasetIds, item.id])} + onRemove={(id) => + assignDatasets(datasetIds.filter((datasetId) => datasetId !== id)) + } + /> + +
+ +
+ useCasesPickerRef.current?.focus(), + }, + { + kind: 'neutral', + content: creatingUseCase ? 'Creating…' : '+ Create New Use Case', + onAction: () => createUseCase(), + }, + ]} + > + assignUseCases([...useCaseIds, item.id])} + onRemove={(id) => + assignUseCases(useCaseIds.filter((useCaseId) => useCaseId !== id)) + } + /> + +
+ setDatasetSheetOpen(false)} + ownerArgs={ownerArgs} + sectors={ + sectorsQuery.data?.sectors?.map((item) => ({ + label: item.name, + value: String(item.id), + })) ?? [] + } + connectedDatasetIds={selectedDatasets.map((row) => row.id)} + intro="Create the essential dataset information and connect it to this Collaborative." + descriptionPlaceholder="Briefly describe what this dataset contains and how it relates to this Collaborative." + successMessage="Dataset created and added to this Collaborative" + onBusy={setCreatingDataset} + onConnect={async (datasetId) => { + const assigned = await GraphQL( + AssignCollaborativeDatasets, + ownerArgs, + { + collaborativeId: params.id, + datasetIds: Array.from(new Set([...datasetIds, datasetId])), + } + ); + const result = assigned.updateCollaborativeDatasets; + if (!result || !('id' in result) || !result.id) { + throw new Error( + 'Unable to connect the dataset to this Collaborative.' + ); + } + }} + onAdded={refresh} + /> +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/EntitySelection.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/EntitySelection.tsx deleted file mode 100644 index 030c78c3..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/EntitySelection.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import Image from 'next/image'; -import { Button, Icon, Text } from 'opub-ui'; - -import { Icons } from '@/components/icons'; -import CustomCombobox from '../../../../usecases/edit/[id]/contributors/CustomCombobox'; - -interface Option { - label: string; - value: string; -} - -interface EntityWithLogo { - id: string; - logo?: { url?: string | null } | null; -} - -interface EntitySectionProps { - title: string; - label: string; - placeholder: string; - options: Option[]; - selectedValues: Option[]; - onChange: (values: Option[]) => void; - onRemove: (value: Option) => void; - data?: EntityWithLogo[] | null; -} - -const EntitySection = ({ - title, - label, - placeholder, - options, - selectedValues, - onChange, - onRemove, - data, -}: EntitySectionProps) => ( -
- {title} -
-
-
- {label} - - onChange([ - ...selectedValues, - ...value.filter( - (val) => - !selectedValues.some((item) => item.value === val.value) - ), - ]) - } - /> -
-
- {selectedValues.map((item) => ( -
-
- org.id === item.value)?.logo?.url - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${ - data?.find((org) => org.id === item.value)?.logo - ?.url - }` - : '/org.png' - } - alt={item.label} - width={140} - height={100} - className="object-contain" - /> -
- -
- ))} -
-
-
-
-); - -export default EntitySection; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/page.tsx index 2bfe9645..76e76465 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/page.tsx @@ -1,548 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; -import Image from 'next/image'; -import { useParams } from 'next/navigation'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, Icon, Text, toast } from 'opub-ui'; +import { RedirectToCollaborativeStep } from '../../RedirectToCollaborativeStep'; -import { GraphQL } from '@/lib/api'; -import { Icons } from '@/components/icons'; -import { Loading } from '@/components/loading'; -import CustomCombobox from '../../../../usecases/edit/[id]/contributors/CustomCombobox'; -import { useEditStatus } from '../../context'; -import EntitySection from './EntitySelection'; -import { - AddContributors, - AddPartners, - AddSupporters, - FetchCollaborativeInfo, - FetchUsers, - OrgList, - RemoveContributor, - RemovePartners, - RemoveSupporters, -} from './query'; - -const Details = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const queryClient = useQueryClient(); - const [searchValue, setSearchValue] = useState(''); - const [formData, setFormData] = useState({ - contributors: [] as { label: string; value: string }[], - supporters: [] as { label: string; value: string }[], - partners: [] as { label: string; value: string }[], - }); - - const COLLAB_CONTRIBUTORS_TOAST_ID = 'collaboratives-contributors-toast'; - - const Users = useQuery( - [`fetch_users`], - () => - GraphQL( - FetchUsers, - { - [params.entityType]: params.entitySlug, - }, - { - limit: 10, - searchTerm: searchValue, - } - ), - { - enabled: searchValue.length > 0, - keepPreviousData: true, - } - ); - - const Organizations = - useQuery([`fetch_orgs`], () => - GraphQL( - OrgList, - { - [params.entityType]: params.entitySlug, - }) - ); - - const CollaborativeData = - useQuery( - [`fetch_collaborative_${params.id}`], - () => - GraphQL( - FetchCollaborativeInfo, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const [prevCollabData, setPrevCollabData] = useState< - typeof CollaborativeData.data | undefined - >(undefined); - if (CollaborativeData.data !== prevCollabData) { - setPrevCollabData(CollaborativeData.data); - setFormData((prev) => ({ - ...prev, - partners: - CollaborativeData?.data?.collaboratives?.[0]?.partnerOrganizations?.map( - (org) => ({ - label: org.name, - value: org.id, - }) - ) || [], - supporters: - CollaborativeData?.data?.collaboratives?.[0]?.supportingOrganizations?.map( - (org) => ({ - label: org.name, - value: org.id, - }) - ) || [], - contributors: - CollaborativeData?.data?.collaboratives?.[0]?.contributors?.map( - (user) => ({ - label: user.fullName, - value: user.id, - }) - ) || [], - })); - } - - const { mutate: addContributor, isLoading: addContributorLoading } = - useMutation( - (input: { collaborativeId: string; userId: string }) => - GraphQL( - AddContributors, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Contributor added successfully', { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`fetch_collaborative_${params.id}`], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - }, - } - ); - - const { mutate: removeContributor, isLoading: removeContributorLoading } = - useMutation( - (input: { collaborativeId: string; userId: string }) => - GraphQL( - RemoveContributor, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Contributor removed successfully', { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`fetch_collaborative_${params.id}`], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - }, - } - ); - - const { mutate: addSupporter, isLoading: addSupporterLoading } = useMutation( - (input: { collaborativeId: string; organizationId: string }) => - GraphQL( - AddSupporters, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Supporter added successfully', { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`fetch_collaborative_${params.id}`], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { id: COLLAB_CONTRIBUTORS_TOAST_ID }); - }, - } - ); - - const { mutate: removeSupporter, isLoading: removeSupporterLoading } = - useMutation( - (input: { collaborativeId: string; organizationId: string }) => - GraphQL( - RemoveSupporters, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Supporter removed successfully', { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`fetch_collaborative_${params.id}`], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - }, - } - ); - - const { mutate: addPartner, isLoading: addPartnerLoading } = useMutation( - (input: { collaborativeId: string; organizationId: string }) => - GraphQL( - AddPartners, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Partner added successfully', { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`fetch_collaborative_${params.id}`], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { id: COLLAB_CONTRIBUTORS_TOAST_ID }); - }, - } - ); - - const { mutate: removePartner, isLoading: removePartnerLoading } = - useMutation( - (input: { collaborativeId: string; organizationId: string }) => - GraphQL( - RemovePartners, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Partner removed successfully', { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`fetch_collaborative_${params.id}`], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { - id: COLLAB_CONTRIBUTORS_TOAST_ID, - }); - }, - } - ); - - useEffect(() => { - Users.refetch(); - }, [searchValue, Users]); - - const selectedContributors = formData.contributors; - - const options = - Users?.data?.searchUsers?.map((user) => ({ - label: user.fullName, - value: user.id, - })) || []; - - const { setStatus } = useEditStatus(); - - useEffect(() => { - setStatus( - [ - addContributorLoading, - removeContributorLoading, - addSupporterLoading, - removeSupporterLoading, - addPartnerLoading, - removePartnerLoading, - ].some(Boolean) - ? 'loading' - : 'success' - ); - }, [ - addContributorLoading, - removeContributorLoading, - addSupporterLoading, - removeSupporterLoading, - addPartnerLoading, - removePartnerLoading, - setStatus, - ]); - - return ( -
- {Users?.isLoading || - Organizations?.data?.allOrganizations?.length === 0 ? ( - - ) : ( -
-
- CONTRIBUTORS -
-
-
- Add Contributors - { - const prevValues = formData.contributors.map( - (item) => item.value - ); - const newlyAdded = newValues.find( - (item) => !prevValues.includes(item.value) - ); - - setFormData((prev) => ({ - ...prev, - contributors: newValues, - })); - - if (newlyAdded) { - addContributor({ - collaborativeId: params.id, - userId: newlyAdded.value, - }); - } - setSearchValue(''); // clear input - }} - placeholder="Add Contributors" - onInput={(value: string) => { - setSearchValue(value); - }} - /> -
-
- {formData.contributors.map((item) => ( -
- contributor.id === item.value - )?.profilePicture?.url - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${ - CollaborativeData.data?.collaboratives?.[0]?.contributors?.find( - (contributor) => - contributor.id === item.value - )?.profilePicture?.url - }` - : '/profile.png' - } - alt={item.label} - width={80} - height={80} - className="rounded-full object-cover" - /> - -
- ))} -
-
-
{' '} -
- - ({ - label: org.name, - value: org.id, - }) - )} - selectedValues={formData.supporters} - onChange={(newValues) => { - const prevValues = formData.supporters.map((item) => item.value); - const newlyAdded = newValues.find( - (item) => !prevValues.includes(item.value) - ); - - setFormData((prev) => ({ ...prev, supporters: newValues })); - - if (newlyAdded) { - addSupporter({ - collaborativeId: params.id, - organizationId: newlyAdded.value, - }); - } - }} - onRemove={(item) => { - setFormData((prev) => ({ - ...prev, - supporters: prev.supporters.filter( - (s) => s.value !== item.value - ), - })); - removeSupporter({ - collaborativeId: params.id, - organizationId: item.value, - }); - }} - /> - - ({ - label: org.name, - value: org.id, - }) - )} - selectedValues={formData.partners} - onChange={(newValues) => { - const prevValues = formData.partners.map((item) => item.value); - const newlyAdded = newValues.find( - (item) => !prevValues.includes(item.value) - ); - - setFormData((prev) => ({ ...prev, partners: newValues })); - - if (newlyAdded) { - addPartner({ - collaborativeId: params.id, - organizationId: newlyAdded.value, - }); - } - }} - onRemove={(item) => { - setFormData((prev) => ({ - ...prev, - partners: prev.partners.filter((s) => s.value !== item.value), - })); - removePartner({ - collaborativeId: params.id, - organizationId: item.value, - }); - }} - /> -
- )} -
- ); -}; - -export default Details; +export default function ContributorsRedirect() { + return ; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/query.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/query.ts index 6b0ffefe..35a9e977 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/query.ts +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/contributors/query.ts @@ -7,6 +7,17 @@ export const FetchUsers = graphql(` id fullName username + profilePicture { + url + } + organizationMemberships { + role { + name + } + organization { + name + } + } } } `); @@ -19,10 +30,17 @@ export const FetchCollaborativeInfo = graphql(` contributors { id fullName - username profilePicture { url } + organizationMemberships { + role { + name + } + organization { + name + } + } } supportingOrganizations { id diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/details/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/details/page.tsx index fd98baf0..8888caf9 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/details/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/details/page.tsx @@ -1,474 +1,7 @@ 'use client'; -import React, { useCallback, useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { graphql } from '@/gql'; -import { CollaborativeInputPartial } from '@/gql/generated/graphql'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { DropZone, Text, TextField, toast } from 'opub-ui'; +import { RedirectToCollaborativeStep } from '../../RedirectToCollaborativeStep'; -import { GraphQL } from '@/lib/api'; -import { RichTextEditor } from '@/components/RichTextEditor'; -import { useEditStatus } from '../../context'; -import Metadata from '../metadata/page'; - -interface UploadedImage { - name?: string | null; - path?: string | null; - url?: string | null; -} - -interface CollaborativeFormData { - title: string; - summary: string; - logo: File | UploadedImage | null; - coverImage: File | UploadedImage | null; - slug: string; - status: string; - startedOn: string | null; - completedOn: string | null; - platformUrl: string; +export default function DetailsRedirect() { + return ; } - -// prettier-ignore -const UpdateCollaborativeMutation = graphql(` - mutation updateCollaborative($data: CollaborativeInputPartial!) { - updateCollaborative(data: $data) { - __typename - id - title - summary - created - modified - slug - status - startedOn - completedOn - platformUrl - logo { - name - path - url - } - coverImage { - name - path - url - } - } - } -`); - -//prettier-ignore -const FetchCollaborative = graphql(` - query CollaborativeData($filters: CollaborativeFilter) { - collaboratives(filters: $filters) { - id - title - summary - platformUrl - logo { - name - path - url - } - coverImage { - name - path - url - } - status - slug - startedOn - completedOn - } - } -`); - -const Details = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - - const queryClient = useQueryClient(); - const COLLAB_DETAILS_TOAST_ID = 'collaboratives-details-toast'; - - const CollaborativeData = - useQuery( - [ - `fetch_CollaborativeData_details`, - params.entityType, - params.entitySlug, - params.id, - ], - () => - GraphQL( - FetchCollaborative, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const CollaborativesData = - CollaborativeData?.data?.collaboratives && - Array.isArray(CollaborativeData?.data?.collaboratives) && - CollaborativeData?.data?.collaboratives?.length > 0 - ? CollaborativeData?.data?.collaboratives[0] - : null; - - const initialFormData = { - title: '', - summary: '', - logo: null as File | UploadedImage | null, - coverImage: null as File | UploadedImage | null, - slug: '', - status: '', - startedOn: null as string | null, - completedOn: null as string | null, - platformUrl: '', - }; - - const [formData, setFormData] = - useState(initialFormData); - const [previousFormData, setPreviousFormData] = - useState(initialFormData); - const [slugError, setSlugError] = useState(null); - - const validateSlug = (value: string) => { - const v = (value ?? '').trim(); - if (!v) return 'URL subdomain is required.'; - if (v.length > 80) return 'URL subdomain must be 80 characters or less.'; - if (v !== v.toLowerCase()) return 'Use lowercase letters only.'; - // lowercase letters, digits, hyphens; no leading/trailing hyphen; no consecutive hyphens - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v)) { - return 'URL subdomain can contain only lowercase letters, numbers, and single hyphens (no spaces).'; - } - return null; - }; - - const [prevCollaborativesData, setPrevCollaborativesData] = useState< - typeof CollaborativesData | undefined - >(undefined); - const [prevDetailsId, setPrevDetailsId] = useState(params.id); - if ( - params.id !== prevDetailsId || - CollaborativesData !== prevCollaborativesData - ) { - setPrevDetailsId(params.id); - setPrevCollaborativesData(CollaborativesData); - if (CollaborativesData) { - const updatedData = { - title: CollaborativesData.title || '', - summary: CollaborativesData.summary || '', - logo: CollaborativesData.logo || null, - coverImage: CollaborativesData.coverImage || null, - slug: CollaborativesData.slug || '', - status: CollaborativesData.status || '', - startedOn: CollaborativesData.startedOn || '', - completedOn: CollaborativesData.completedOn || '', - platformUrl: CollaborativesData.platformUrl || '', - }; - setFormData(updatedData); - setPreviousFormData(updatedData); - } - } - - const { mutate, isLoading: editMutationLoading } = useMutation( - (data: { data: CollaborativeInputPartial }) => - GraphQL( - UpdateCollaborativeMutation, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: (res) => { - toast('Collaborative updated successfully', { - id: COLLAB_DETAILS_TOAST_ID, - }); - setFormData((prev) => ({ - ...prev, - title: res.updateCollaborative.title ?? prev.title, - summary: res.updateCollaborative.summary ?? prev.summary, - logo: res.updateCollaborative.logo ?? prev.logo, - coverImage: res.updateCollaborative.coverImage ?? prev.coverImage, - slug: res.updateCollaborative.slug ?? prev.slug, - status: res.updateCollaborative.status ?? prev.status, - startedOn: res.updateCollaborative.startedOn ?? prev.startedOn, - completedOn: res.updateCollaborative.completedOn ?? prev.completedOn, - platformUrl: res.updateCollaborative.platformUrl ?? prev.platformUrl, - })); - setPreviousFormData((prev) => ({ - ...prev, - title: res.updateCollaborative.title ?? prev.title, - summary: res.updateCollaborative.summary ?? prev.summary, - logo: res.updateCollaborative.logo ?? prev.logo, - coverImage: res.updateCollaborative.coverImage ?? prev.coverImage, - slug: res.updateCollaborative.slug ?? prev.slug, - status: res.updateCollaborative.status ?? prev.status, - startedOn: res.updateCollaborative.startedOn ?? prev.startedOn, - completedOn: res.updateCollaborative.completedOn ?? prev.completedOn, - platformUrl: res.updateCollaborative.platformUrl ?? prev.platformUrl, - })); - - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeData_details`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - let msg = 'Something went wrong'; - if (typeof error === 'object' && error !== null) { - if ( - 'response' in error && - typeof error.response === 'object' && - error.response !== null && - 'errors' in error.response && - Array.isArray(error.response.errors) && - typeof error.response.errors[0]?.message === 'string' - ) { - msg = error.response.errors[0].message; - } else if ('message' in error && typeof error.message === 'string') { - msg = error.message; - } - } - toast(`Error: ${msg}`, { id: COLLAB_DETAILS_TOAST_ID }); - }, - } - ); - - const handleChange = useCallback( - (field: keyof CollaborativeFormData, value: CollaborativeFormData[keyof CollaborativeFormData]) => { - setFormData((prevData) => ({ - ...prevData, - [field]: value, - })); - }, - [] - ); - - const onDrop = React.useCallback( - (_dropFiles: File[], acceptedFiles: File[]) => { - mutate({ - data: { - id: params.id.toString(), - logo: acceptedFiles[0], - }, - }); - }, - [mutate, params.id] - ); - - const onCoverImageDrop = React.useCallback( - (_dropFiles: File[], acceptedFiles: File[]) => { - mutate({ - data: { - id: params.id.toString(), - coverImage: acceptedFiles[0], - }, - }); - }, - [mutate, params.id] - ); - - const handleSave = (updatedData: CollaborativeFormData) => { - const slugErr = validateSlug(updatedData?.slug || ''); - if (slugErr) { - return; - } - - if (JSON.stringify(updatedData) !== JSON.stringify(previousFormData)) { - setPreviousFormData(updatedData); - - mutate({ - data: { - id: params.id.toString(), - title: updatedData.title, - summary: updatedData.summary, - startedOn: updatedData.startedOn || null, - completedOn: updatedData.completedOn || null, - platformUrl: updatedData.platformUrl || '', - slug: updatedData.slug || '', - }, - }); - } - }; - - const { setStatus } = useEditStatus(); - - useEffect(() => { - setStatus(editMutationLoading ? 'loading' : 'success'); - }, [editMutationLoading, setStatus]); - - // Show loading state while fetching data - if (CollaborativeData.isLoading) { - return
Loading collaborative data...
; - } - - // Show error if no data found - if (!CollaborativesData) { - return
No collaborative data found
; - } - - return ( -
-
- handleChange('summary', value)} - onBlur={() => handleSave(formData)} - placeholder="Enter collaborative summary with rich formatting..." - helpText={`Character limit: ${formData?.summary?.length || 0}/10000`} - /> -
-
-
- handleChange('platformUrl', e)} - onBlur={() => handleSave(formData)} - /> -
-
-
- { - setSlugError(null); - handleChange('slug', e); - }} - onBlur={() => { - const slugErr = validateSlug(formData.slug || ''); - setSlugError(slugErr); - // if (slugErr) { - // toast(`Error: ${slugErr}`, { id: COLLAB_DETAILS_TOAST_ID }); - // return; - // } - handleSave(formData); - }} - /> - {slugError ? ( - - {slugError} - - ) : null} -
- - - . - {process.env.NEXT_PUBLIC_PLATFORM_DOMAIN?.includes('localhost') - ? `${process.env.NEXT_PUBLIC_PLATFORM_DOMAIN}:${process.env.NEXT_PUBLIC_PLATFORM_PORT}` - : process.env.NEXT_PUBLIC_PLATFORM_DOMAIN} - -
-
- - - -
-
- { - handleChange('startedOn', e); - }} - onBlur={() => handleSave(formData)} - /> -
- -
- { - handleChange('completedOn', e); - }} - onBlur={() => handleSave(formData)} - /> -
-
- -
- - - -
- -
- - - -
-
- ); -}; - -export default Details; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/layout.tsx new file mode 100644 index 00000000..8efcc385 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/layout.tsx @@ -0,0 +1,16 @@ +import { EditLayout } from '../components/EditLayout'; +import { CollaborativeEditStatusProvider } from '../context'; + +interface LayoutProps { + children?: React.ReactNode; + params: Promise<{ id: string }>; +} + +export default async function Layout({ children, params }: LayoutProps) { + const resolvedParams = await params; + return ( + + {children} + + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/metadata/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/metadata/page.tsx index 4da96aa3..d21bb666 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/metadata/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/metadata/page.tsx @@ -1,637 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { graphql } from '@/gql'; -import { - MetadataModels, - UpdateCollaborativeMetadataInput, -} from '@/gql/generated/graphql'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Combobox, Spinner, toast } from 'opub-ui'; +import { RedirectToCollaborativeStep } from '../../RedirectToCollaborativeStep'; -import { GraphQL } from '@/lib/api'; -import { useEditStatus } from '../../context'; - -interface SelectOption { - label: string; - value: string; -} - -interface MetadataField { - id: string; - label: string; - dataType: string; - options?: string[] | null; -} - -type MetadataFormValue = string | SelectOption[] | SelectOption | undefined; - -interface MetadataFormData { - [key: string]: MetadataFormValue; -} - -interface MetadataSource { - metadata?: Array<{ - value?: string | null; - metadataItem: { id: string; dataType: string }; - }> | null; - sectors?: Array<{ id: string; name?: string | null }> | null; - sdgs?: Array<{ - id: string; - name?: string | null; - code: string; - number?: number | null; - }> | null; - tags?: Array<{ id: string; value?: string | null }> | null; - geographies?: Array<{ id: string; name?: string | null }> | null; -} - -function comboValues(value: MetadataFormValue, key: 'value' | 'label'): string[] { - if (!Array.isArray(value)) return []; - return value.map((item) => { - if (typeof item === 'object' && item !== null) { - return String(item[key] ?? ''); - } - return String(item); - }); -} - -function asSelectOptions(value: MetadataFormValue): SelectOption[] { - return Array.isArray(value) ? value : []; -} - -function comboboxSelected( - value: MetadataFormValue -): string | SelectOption[] | undefined { - if (value === undefined) return undefined; - if (typeof value === 'string') return value; - if (Array.isArray(value)) return value; - return [value]; -} - -function metadataValue(value: MetadataFormValue): string { - if (Array.isArray(value)) { - return value.map((item) => item.value || String(item)).join(', '); - } - if (typeof value === 'string') return value; - return value?.value ?? ''; +export default function MetadataRedirect() { + return ; } - -// prettier-ignore -const FetchCollaborativeMetadata = graphql(` - query CollaborativeMetadata($filters: CollaborativeFilter) { - collaboratives(filters: $filters) { - id - metadata { - metadataItem { - id - label - dataType - } - id - value - } - tags { - id - value - } - sectors { - id - name - } - sdgs { - id - code - name - number - } - geographies { - id - name - code - type - } - } - } -`); - -// prettier-ignore -const metadataQueryDoc = graphql(` - query CollaborativeMetaDataList($filters: MetadataFilter) { - metadata(filters: $filters) { - id - label - dataStandard - urn - dataType - options - filterable - } - } -`); - -// prettier-ignore -const sectorsListQueryDoc = graphql(` - query SectorList { - sectors { - id - name - } - } -`); - -// prettier-ignore -const sdgsListQueryDoc = graphql(` - query SDGList { - sdgs { - id - code - name - number - } - } -`); - -// prettier-ignore -const tagsListQueryDoc = graphql(` - query TagsList { - tags { - id - value - } - } -`); - -// prettier-ignore -const geographiesListQueryDoc = graphql(` - query GeographiesList { - geographies { - id - name - code - type - parentId { - id - name - } - } - } -`); - -// prettier-ignore -const UpdateCollaborativeMetadata = graphql(` - mutation addUpdateCollaborativeMetadata($updateMetadataInput: UpdateCollaborativeMetadataInput!) { - addUpdateCollaborativeMetadata(updateMetadataInput: $updateMetadataInput) { - __typename - ... on TypeCollaborative { - id - metadata { - metadataItem { - id - label - dataType - } - id - value - } - tags { - id - value - } - sectors { - id - name - } - sdgs { - id - code - name - number - } - geographies { - id - name - code - type - } - } - } - } -`); - -const Metadata = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - - const { setStatus } = useEditStatus(); - const queryClient = useQueryClient(); - - const collaborativeData = useQuery( - [ - `fetch_CollaborativeData_Metadata`, - params.entityType, - params.entitySlug, - params.id, - ], - () => - GraphQL( - FetchCollaborativeMetadata, - { - [params.entityType]: params.entitySlug, - }, - { filters: { id: params.id } } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const { data: metadataFields } = useQuery( - [`metadata_fields_COLLABORATIVE_${params.id}`], - () => - GraphQL( - metadataQueryDoc, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - model: 'COLLABORATIVE' as MetadataModels, - enabled: true, - }, - } - ) - ); - - const defaultValuesPrepFn = (data: MetadataSource): MetadataFormData => { - const defaultVal: MetadataFormData = {}; - - data?.metadata?.map((field) => { - if (field.metadataItem.dataType === 'MULTISELECT' && field.value) { - defaultVal[field.metadataItem.id] = field.value - .split(', ') - .map((value: string) => ({ - label: value, - value: value, - })); - } else if (!field.value) { - defaultVal[field.metadataItem.id] = []; - } else { - defaultVal[field.metadataItem.id] = field.value; - } - }); - - defaultVal['sectors'] = - data?.sectors?.map((sector) => { - return { - label: sector.name ?? '', - value: sector.id, - }; - }) || []; - - defaultVal['sdgs'] = - data?.sdgs?.map((sdg) => { - const num = sdg.number - ? String(sdg.number).padStart(2, '0') - : sdg.code.replace('SDG', '').padStart(2, '0'); - return { - label: `${num}. ${sdg.name ?? ''}`, - value: sdg.id, - }; - }) || []; - - defaultVal['tags'] = - data?.tags?.map((tag) => { - return { - label: tag.value ?? '', - value: tag.id, - }; - }) || []; - - defaultVal['geographies'] = - data?.geographies?.map((geo) => { - return { - label: geo.name ?? '', - value: geo.id, - }; - }) || []; - - return defaultVal; - }; - - const [formData, setFormData] = useState( - defaultValuesPrepFn(collaborativeData?.data?.collaboratives?.[0] || {}) - ); - const [previousFormData, setPreviousFormData] = useState(formData); - const [prevCollaborativeData, setPrevCollaborativeData] = useState( - collaborativeData.data - ); - if (collaborativeData.data !== prevCollaborativeData) { - setPrevCollaborativeData(collaborativeData.data); - if (collaborativeData.data?.collaboratives?.[0]) { - const updatedData = defaultValuesPrepFn( - collaborativeData.data.collaboratives[0] - ); - setFormData(updatedData); - setPreviousFormData(updatedData); - } - } - - const getSectorsList = - useQuery([`sectors_list_query`], () => - GraphQL( - sectorsListQueryDoc, - { - [params.entityType]: params.entitySlug, - }) - ); - - const getSDGsList = useQuery( - [`sdgs_list_query`], - () => - GraphQL( - sdgsListQueryDoc, - { - [params.entityType]: params.entitySlug, - }) - ); - - const getTagsList = useQuery([`tags_list_query`], () => - GraphQL( - tagsListQueryDoc, - { - [params.entityType]: params.entitySlug, - }) - ); - - const getGeographiesList = - useQuery([`geographies_list_query`], () => - GraphQL( - geographiesListQueryDoc, - { - [params.entityType]: params.entitySlug, - }) - ); - const [isTagsListUpdated, setIsTagsListUpdated] = useState(false); - - const COLLAB_METADATA_TOAST_ID = 'collaboratives-metadata-toast'; - - // Update mutation - const updateCollaborative = useMutation( - (data: { updateMetadataInput: UpdateCollaborativeMetadataInput }) => - GraphQL( - UpdateCollaborativeMetadata, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: (res) => { - toast('Collaborative updated successfully', { - id: COLLAB_METADATA_TOAST_ID, - }); - const result = res.addUpdateCollaborativeMetadata; - if (result.__typename === 'TypeCollaborative') { - const updatedData = defaultValuesPrepFn(result); - if (isTagsListUpdated) { - getTagsList.refetch(); - setIsTagsListUpdated(false); - } - setFormData(updatedData); - setPreviousFormData(updatedData); - } - - // Keep other edit tabs in sync (Details/Publish) without requiring a full reload. - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeData_details`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeData_Metadata`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { id: COLLAB_METADATA_TOAST_ID }); - }, - } - ); - - const handleChange = (field: string, value: MetadataFormValue) => { - setFormData((prevData) => ({ - ...prevData, - [field]: value, - })); - }; - - const handleSave = (updatedData: MetadataFormData) => { - if (JSON.stringify(updatedData) !== JSON.stringify(previousFormData)) { - setPreviousFormData(updatedData); - - updateCollaborative.mutate({ - updateMetadataInput: { - id: params.id, - metadata: Object.keys(updatedData) - .filter( - (key) => - !['tags', 'sectors', 'sdgs'].includes(key) && - metadataFields?.metadata?.find((item) => item.id === key) - ) - .map((key) => ({ - id: key, - value: metadataValue(updatedData[key]), - })), - sectors: comboValues(updatedData.sectors, 'value'), - sdgs: comboValues(updatedData.sdgs, 'value'), - tags: comboValues(updatedData.tags, 'label'), - geographies: comboValues(updatedData.geographies, 'value').map((value) => - parseInt(value, 10) - ), - }, - }); - } - }; - - useEffect(() => { - setStatus(updateCollaborative.isLoading ? 'loading' : 'success'); - }, [updateCollaborative.isLoading, setStatus]); - - if ( - getSectorsList.isLoading || - getSDGsList.isLoading || - getTagsList.isLoading || - getGeographiesList.isLoading || - collaborativeData.isLoading - ) { - return ( -
- -
- ); - } - - function renderInputField(metadataFormItem: MetadataField) { - if (metadataFormItem.dataType === 'SELECT') { - return ( -
- ({ - label: option, - value: option, - })) || [] - } - label={metadataFormItem.label} - selectedValue={comboboxSelected(formData[metadataFormItem.id])} - displaySelected - onChange={(value) => { - handleChange(metadataFormItem.id, value); - handleSave({ ...formData, [metadataFormItem.id]: value }); - }} - /> -
- ); - } - - if (metadataFormItem.dataType === 'MULTISELECT') { - return ( -
- ({ - label: option, - value: option, - })) || []), - ]} - label={metadataFormItem.label + ' *'} - selectedValue={comboboxSelected(formData[metadataFormItem.id])} - displaySelected - onChange={(value) => { - handleChange(metadataFormItem.id, value); - handleSave({ ...formData, [metadataFormItem.id]: value }); - }} - /> -
- ); - } - } - - return ( -
-
-
-
- { - const num = item.number - ? String(item.number).padStart(2, '0') - : item.code.replace('SDG', '').padStart(2, '0'); - return { - label: `${num}. ${item.name}`, - value: item.id, - }; - }) || [] - } - selectedValue={asSelectOptions(formData.sdgs)} - onChange={(value) => { - handleChange('sdgs', value); - handleSave({ ...formData, sdgs: value }); - }} - /> -
-
- ({ - label: item.value ?? '', - value: item.id, - })) || [] - } - key={`tags-${getTagsList.data?.tags?.length}`} - selectedValue={asSelectOptions(formData.tags)} - onChange={(value) => { - setIsTagsListUpdated(true); - handleChange('tags', value); - handleSave({ ...formData, tags: value }); - }} - /> -
-
- ({ - label: item.name, - value: item.id, - })) || [] - } - selectedValue={asSelectOptions(formData.sectors)} - onChange={(value) => { - handleChange('sectors', value); - handleSave({ ...formData, sectors: value }); - }} - /> -
-
- ({ - label: `${item.name}${item.parentId ? ` (${item.parentId.name})` : ''}`, - value: item.id, - })) || [] - } - selectedValue={asSelectOptions(formData.geographies)} - onChange={(value) => { - handleChange('geographies', value); - handleSave({ ...formData, geographies: value }); - }} - /> -
-
- -
- {metadataFields?.metadata?.map((item) => renderInputField(item))} -
-
-
- ); -}; - -export default Metadata; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/page.tsx new file mode 100644 index 00000000..3faa8405 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/page.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { firstIncompleteCollaborativeEditStep } from '../collaborative-summary'; +import { FetchCollaborativeReview } from '../wizard-documents'; + +export default function CollaborativeEditIndexPage() { + const router = useRouter(); + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + + const summaryQuery = useQuery([`collaborative_wizard_${params.id}`], () => + GraphQL( + FetchCollaborativeReview, + { + [params.entityType]: params.entitySlug, + }, + { + filters: { + id: params.id, + }, + } + ) + ); + + const collaborative = summaryQuery.data?.collaboratives?.[0]; + + useEffect(() => { + if (summaryQuery.isLoading) return; + const step = collaborative + ? firstIncompleteCollaborativeEditStep(collaborative) + : 'about'; + router.replace( + `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/${step}` + ); + }, [ + collaborative, + summaryQuery.isLoading, + params.entitySlug, + params.entityType, + params.id, + router, + ]); + + return ( +
+ +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/people/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/people/page.tsx new file mode 100644 index 00000000..123fd8b7 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/people/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Spinner, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { AddOrganisationSheet } from '../../../../usecases/edit/components/AddOrganisationSheet'; +import { type EntityRow } from '../../../../usecases/edit/components/EntityRowPicker'; +import { + PeopleOrganisationsSection, + peopleOrgRoleMessage, + personSubtitle, + rawEntityId, +} from '../../../../usecases/edit/components/PeopleOrganisationsSection'; +import { errorMessage } from '../../collaborative-summary'; +import { useCollaborativeEditStatus } from '../../context'; +import styles from '../../edit.module.scss'; +import { + AddContributors, + AddPartners, + AddSupporters, + FetchCollaborativeInfo, + FetchUsers, + OrgList, + RemoveContributor, + RemovePartners, + RemoveSupporters, +} from '../contributors/query'; + +function fileUrl(url?: string | null) { + if (!url) return ''; + if (url.startsWith('http')) return url; + return `${process.env.NEXT_PUBLIC_BACKEND_URL}/${url.replace('/code/files/', '')}`; +} + +export default function PeoplePage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + const queryClient = useQueryClient(); + const { setStatus } = useCollaborativeEditStatus(); + const ownerArgs = { [params.entityType]: params.entitySlug }; + const [userSearch, setUserSearch] = useState(''); + const [orgSheetOpen, setOrgSheetOpen] = useState(false); + const [creatingOrganisation, setCreatingOrganisation] = useState(false); + + const collaborativeQuery = useQuery( + [`fetch_collaborative_people_${params.id}`], + () => + GraphQL(FetchCollaborativeInfo, ownerArgs, { + filters: { id: params.id }, + }), + { refetchOnMount: true } + ); + const usersQuery = useQuery( + [`collaborative_wizard_users`, userSearch], + () => + GraphQL(FetchUsers, ownerArgs, { + limit: 10, + searchTerm: userSearch, + }), + { enabled: userSearch.trim().length > 0, keepPreviousData: true } + ); + const orgsQuery = useQuery([`collaborative_wizard_orgs`], () => + GraphQL(OrgList, ownerArgs) + ); + + const refresh = () => { + void collaborativeQuery.refetch(); + void queryClient.invalidateQueries({ + queryKey: [`collaborative_wizard_${params.id}`], + }); + }; + + const onError = (error: unknown) => { + toast(errorMessage(error, 'Unable to update people right now.')); + }; + + const { mutate: addContributor, isLoading: addingContributor } = useMutation( + (userId: string) => + GraphQL(AddContributors, ownerArgs, { + collaborativeId: params.id, + userId, + }), + { onSuccess: refresh, onError } + ); + const { mutate: removeContributor, isLoading: removingContributor } = + useMutation( + (userId: string) => + GraphQL(RemoveContributor, ownerArgs, { + collaborativeId: params.id, + userId, + }), + { onSuccess: refresh, onError } + ); + const { mutate: addPartner, isLoading: addingPartner } = useMutation( + (organizationId: string) => + GraphQL(AddPartners, ownerArgs, { + collaborativeId: params.id, + organizationId, + }), + { onSuccess: refresh, onError } + ); + const { mutate: removePartner, isLoading: removingPartner } = useMutation( + (organizationId: string) => + GraphQL(RemovePartners, ownerArgs, { + collaborativeId: params.id, + organizationId, + }), + { onSuccess: refresh, onError } + ); + const { mutate: addSupporter, isLoading: addingSupporter } = useMutation( + (organizationId: string) => + GraphQL(AddSupporters, ownerArgs, { + collaborativeId: params.id, + organizationId, + }), + { onSuccess: refresh, onError } + ); + const { mutate: removeSupporter, isLoading: removingSupporter } = + useMutation( + (organizationId: string) => + GraphQL(RemoveSupporters, ownerArgs, { + collaborativeId: params.id, + organizationId, + }), + { onSuccess: refresh, onError } + ); + + useEffect(() => { + setStatus( + addingContributor || + removingContributor || + addingPartner || + removingPartner || + addingSupporter || + removingSupporter || + creatingOrganisation + ? 'loading' + : 'success' + ); + }, [ + addingContributor, + removingContributor, + addingPartner, + removingPartner, + addingSupporter, + removingSupporter, + creatingOrganisation, + setStatus, + ]); + + if (collaborativeQuery.isLoading || orgsQuery.isLoading) { + return ( +
+ +
+ ); + } + + const collaborative = collaborativeQuery.data?.collaboratives?.[0]; + + const partnerIds = new Set( + collaborative?.partnerOrganizations?.map((item) => item.id) ?? [] + ); + const selected: EntityRow[] = [ + ...(collaborative?.contributors?.map((item) => ({ + id: `user:${item.id}`, + kind: 'person' as const, + role: 'contributor', + title: item.fullName, + subtitle: personSubtitle(item.organizationMemberships), + imageUrl: fileUrl(item.profilePicture?.url), + })) ?? []), + ...(collaborative?.partnerOrganizations?.map((item) => ({ + id: `org:${item.id}`, + kind: 'org' as const, + role: 'partner', + title: item.name, + subtitle: 'Registered organisation', + imageUrl: fileUrl(item.logo?.url), + })) ?? []), + ...(collaborative?.supportingOrganizations + ?.filter((item) => !partnerIds.has(item.id)) + .map((item) => ({ + id: `org:${item.id}`, + kind: 'org' as const, + role: 'supporter', + title: item.name, + subtitle: 'Registered organisation', + imageUrl: fileUrl(item.logo?.url), + })) ?? []), + ]; + + const selectedIds = new Set(selected.map((item) => item.id)); + const options: EntityRow[] = [ + ...(usersQuery.data?.searchUsers?.map((item) => ({ + id: `user:${item.id}`, + kind: 'person' as const, + role: 'contributor', + title: item.fullName, + subtitle: personSubtitle(item.organizationMemberships), + imageUrl: fileUrl(item.profilePicture?.url) || undefined, + })) ?? []), + ...(orgsQuery.data?.allOrganizations?.map((item) => ({ + id: `org:${item.id}`, + kind: 'org' as const, + role: 'partner', + title: item.name, + subtitle: 'Registered organisation', + imageUrl: fileUrl(item.logo?.url) || undefined, + })) ?? []), + ].filter((item) => !selectedIds.has(item.id)); + + const removeMember = (item: EntityRow) => { + const id = rawEntityId(item.id); + if (item.role === 'partner') removePartner(id); + else if (item.role === 'supporter') removeSupporter(id); + else removeContributor(id); + }; + + const changeRole = (item: EntityRow, role: string) => { + if (role === item.role) return; + const blocked = peopleOrgRoleMessage(item, role); + if (blocked) { + toast(blocked); + return; + } + const id = rawEntityId(item.id); + const add = role === 'supporter' ? addSupporter : addPartner; + if (item.role === 'partner') { + removePartner(id, { + onSuccess: () => add(id), + }); + return; + } + if (item.role === 'supporter') { + removeSupporter(id, { + onSuccess: () => add(id), + }); + } + }; + + return ( +
+ 0 && usersQuery.isFetching} + onSearch={setUserSearch} + onRoleChange={changeRole} + onAddOrganisation={() => setOrgSheetOpen(true)} + onAdd={(item) => { + const id = rawEntityId(item.id); + if (item.kind === 'org') addPartner(id); + else addContributor(id); + }} + onRemove={(id) => { + const item = selected.find((row) => row.id === id); + if (item) removeMember(item); + }} + /> + setOrgSheetOpen(false)} + description="Add an organisation to this Collaborative." + onConnect={(organizationId) => + GraphQL(AddPartners, ownerArgs, { + collaborativeId: params.id, + organizationId, + }) + } + onBusy={setCreatingOrganisation} + onAdded={() => { + void orgsQuery.refetch(); + refresh(); + }} + /> +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/publish/page.tsx index d60bed40..beb37200 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/publish/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/publish/page.tsx @@ -1,342 +1,238 @@ 'use client'; import { useParams, useRouter } from 'next/navigation'; -import { graphql } from '@/gql'; +import { IconAlertTriangle, IconCircleCheck, IconSend } from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, - Button, - Icon, - Spinner, - Text, - toast, -} from 'opub-ui'; +import { Button, Spinner, Text, toast } from 'opub-ui'; import { GraphQL } from '@/lib/api'; -import { Icons } from '@/components/icons'; -import Assign from './Assign'; -import Contributors from './Contributors'; -import Details from './Details'; - -// prettier-ignore -const CollaborativeDetails = graphql(` - query CollabDetails($filters: CollaborativeFilter) { - collaboratives(filters: $filters) { - id - title - summary - website - platformUrl - metadata { - metadataItem { - id - label - dataType - } - id - value - } - sectors { - id - name - } - sdgs { - id - code - name - } - tags { - id - value - } - startedOn - completedOn - logo { - name - path - url - } - coverImage { - name - path - url - } - datasets { - title - id - sectors { - name - } - modified - } - useCases { - title - id - slug - sectors { - name - } - modified - } - contactEmail - status - slug - contributors { - id - fullName - username - profilePicture { - url - } - } - supportingOrganizations { - id - name - logo { - url - name - } - } - partnerOrganizations { - id - name - logo { - url - name - } - } - } - } -`); +import { + aboutEditTarget, + contentEditTarget, + errorMessage, + isCollaborativeAboutComplete, + isCollaborativeContentComplete, + plainSummary, +} from '../../collaborative-summary'; +import { + FetchCollaborativeReview, + publishCollaborativeMutation, +} from '../../wizard-documents'; -const publishCollaborativeMutation = graphql(` - mutation publishCollaborative($collaborativeId: String!) { - publishCollaborative(collaborativeId: $collaborativeId) { - ... on TypeCollaborative { - id - status - } - } - } -`); +function ReadinessRow({ + ok, + label, + detail, + onEdit, +}: { + ok: boolean; + label: string; + detail: string; + onEdit?: () => void; +}) { + return ( +
+
+ {ok ? ( + + ) : ( + + )} +
+ {label} +
+ + {detail} + +
+
+
+ {onEdit ? ( + + ) : null} +
+ ); +} -const Publish = () => { +export default function PublishPage() { const params = useParams<{ entityType: string; entitySlug: string; id: string; }>(); - const CollaborativeData = - useQuery( - [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - () => - GraphQL( - CollaborativeDetails, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - // We navigate between tabs via routing; always refetch to avoid showing stale cached data. - refetchOnMount: 'always', - refetchOnReconnect: 'always', - } - ); const router = useRouter(); - const PUBLISH_SUCCESS_TOAST_ID = 'collaboratives-publish-toast'; - const PUBLISH_ERROR_TOAST_ID = 'collaboratives-publish-toast'; + const ownerArgs = { [params.entityType]: params.entitySlug }; + const stepBase = `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}`; + + const reviewQuery = useQuery( + [`collaborative_wizard_${params.id}`], + () => + GraphQL(FetchCollaborativeReview, ownerArgs, { + filters: { id: params.id }, + }), + { refetchOnMount: 'always' } + ); - const { mutate, isLoading: mutationLoading } = useMutation( + const { mutate, isLoading: publishing } = useMutation( () => - GraphQL( - publishCollaborativeMutation, - { - [params.entityType]: params.entitySlug, - }, - { collaborativeId: params.id } - ), + GraphQL(publishCollaborativeMutation, ownerArgs, { + collaborativeId: params.id, + }), { onSuccess: () => { - toast('Collaborative Published Successfully', { - id: PUBLISH_SUCCESS_TOAST_ID, - }); + toast('Collaborative published successfully'); router.push( `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives` ); }, - onError: (err: unknown) => { - const errorMessage = - typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' && err.message.trim() - ? err.message.trim() - : 'Unable to publish collaborative right now. Please try again.'; - toast(`Error: ${errorMessage}`, { id: PUBLISH_ERROR_TOAST_ID }); + onError: (error: unknown) => { + toast(errorMessage(error, 'Unable to publish this collaborative.')); }, } ); - const Summary = [ - { - name: 'Details', - data: CollaborativeData.data?.collaboratives, - error: - CollaborativeData.data?.collaboratives?.[0]?.sectors?.length === 0 || - CollaborativeData.data?.collaboratives?.[0]?.summary?.length === 0 || - CollaborativeData.data?.collaboratives?.[0]?.sdgs?.length === 0 || - CollaborativeData.data?.collaboratives?.[0]?.logo === null || - CollaborativeData.data?.collaboratives?.[0]?.coverImage === null - ? 'Summary, SDG, Sectors, Logo, or Cover Image is missing. Please add to continue.' - : '', - errorType: 'critical', - }, - { - name: 'Datasets', - data: CollaborativeData?.data?.collaboratives?.[0]?.datasets, - error: - CollaborativeData.data && - CollaborativeData.data?.collaboratives?.[0]?.datasets?.length === 0 - ? 'No datasets assigned. Please assign to continue.' - : '', - }, - { - name: 'Use Cases', - data: CollaborativeData?.data?.collaboratives?.[0]?.useCases, - error: '', - }, - // { - // name: 'Dashboards', - // data: CollaborativeData?.data?.collaboratives[0]?.length > 0, - // error: '', - // }, - { - name: 'Contributors', - data: CollaborativeData?.data?.collaboratives?.[0] != null && - 'length' in CollaborativeData.data.collaboratives[0] && - typeof CollaborativeData.data.collaboratives[0].length === 'number' && - CollaborativeData.data.collaboratives[0].length > 0, - error: '', - }, - ]; + const collaborative = reviewQuery.data?.collaboratives?.[0]; + + if (reviewQuery.isLoading) { + return ( +
+ +
+ ); + } - const isPublishDisabled = (collaborative: { - datasets?: unknown[] | null; - sectors?: unknown[] | null; - summary?: string | null; - sdgs?: unknown[] | null; - logo?: unknown; - coverImage?: unknown; - } | null | undefined) => { - if (!collaborative) return true; + const aboutOk = isCollaborativeAboutComplete(collaborative ?? {}); + const contentOk = isCollaborativeContentComplete(collaborative ?? {}); + const contributorCount = collaborative?.contributors?.length ?? 0; + const partnerCount = collaborative?.partnerOrganizations?.length ?? 0; + const supporterCount = collaborative?.supportingOrganizations?.length ?? 0; + const peopleCount = contributorCount + partnerCount + supporterCount; + const ready = aboutOk && contentOk; + const published = collaborative?.status === 'PUBLISHED'; + const previewHref = `/collaboratives/${collaborative?.slug || params.id}`; - const hasDatasets = (collaborative.datasets?.length ?? 0) > 0; - const hasRequiredMetadata = - (collaborative.sectors?.length ?? 0) > 0 && - (collaborative.summary?.length ?? 0) > 0 && - (collaborative.sdgs?.length ?? 0) > 0 && - collaborative.logo !== null && - collaborative.coverImage !== null; + const aboutDetail = aboutOk + ? 'Name, description, sectors, and SDG goals added.' + : [ + !collaborative?.title?.trim() ? 'Enter a Collaborative name.' : null, + !plainSummary(collaborative?.summary) + ? 'Add a description to continue.' + : null, + (collaborative?.sectors?.length ?? 0) === 0 + ? 'Select at least one sector.' + : null, + (collaborative?.sdgs?.length ?? 0) === 0 + ? 'Select at least one SDG goal.' + : null, + ] + .filter(Boolean) + .join(' '); - // No datasets assigned - if (!hasDatasets) return true; + const relationshipBits = [ + contributorCount ? `${contributorCount} Contributor` : null, + partnerCount ? `${partnerCount} Partner` : null, + supporterCount ? `${supporterCount} Supporter` : null, + ].filter(Boolean); - // Required metadata check - if (!hasRequiredMetadata) return true; - }; + const peopleDetail = + peopleCount > 0 + ? `${peopleCount} people and organisations added${ + relationshipBits.length ? ` — ${relationshipBits.join(', ')}` : '' + }` + : 'No people or organisations added yet.'; + + const datasetCount = collaborative?.datasets?.length ?? 0; + const useCaseCount = collaborative?.useCases?.length ?? 0; + const contentDetail = contentOk + ? `${datasetCount} dataset${datasetCount === 1 ? '' : 's'} and ${useCaseCount} use case${useCaseCount === 1 ? '' : 's'} connected.` + : 'Add at least one dataset or use case to continue.'; return ( - <> -
-
- - REVIEW COLLABORATIVE DETAILS - - : - - Please check all the Collaborative details below before publishing +
+
+ + Review & Publish + +
+ + Check your information before making this content available + publicly.
-
- {CollaborativeData.isLoading || mutationLoading ? ( -
- -
- ) : ( - <> - {Summary.map((item, index) => ( - - - -
- - {item.name} - - {item.error !== '' && ( -
- - - {item.error} - -
- )} -
-
- -
- {item.name === 'Datasets' ? ( - - ) : item.name === 'Use Cases' ? ( - - ) : item.name === 'Details' ? ( -
- ) : ( - - )} -
-
-
-
- ))} - - - )} +
+ +
+
+ + {ready ? 'Ready to publish' : 'Needs attention'} +
+ + router.push( + `${stepBase}/about#${aboutEditTarget(collaborative ?? {})}` + ) + } + /> + + + router.push( + `${stepBase}/content#${contentEditTarget(collaborative ?? {})}` + ) + } + /> +
+ +
+ + Open a full preview of this Collaborative in a new tab, exactly as it + will appear once published. + + + + Publishing happens from this review step. +
- - ); -}; -export default Publish; +
+ +
+ +
+ +
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/usecases/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/usecases/page.tsx index f0ce441f..98a8326d 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/usecases/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/usecases/page.tsx @@ -1,211 +1,7 @@ 'use client'; -import React, { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { fetchData } from '@/fetch'; -import { graphql } from '@/gql'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, DataTable, Text, toast } from 'opub-ui'; +import { RedirectToCollaborativeStep } from '../../RedirectToCollaborativeStep'; -import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import { Loading } from '@/components/loading'; - -interface SearchUseCase { - id: string | number; - title: string; - modified?: string; - sectors?: Array<{ name?: string } | string>; -} - -interface AssignTableRow { - id: string; - title: string; - category: string; - modified: string; +export default function UseCasesRedirect() { + return ; } - -// prettier-ignore -const FetchCollaborativeDetails = graphql(` - query CollaborativeUseCaseDetails($filters: CollaborativeFilter) { - collaboratives(filters: $filters) { - id - title - useCases { - id - title - slug - modified - sectors { - name - } - } - } - } -`); - -// prettier-ignore -const AssignCollaborativeUseCases = graphql(` - mutation assignCollaborativeUseCases($collaborativeId: String!, $useCaseIds: [String!]!) { - updateCollaborativeUseCases(collaborativeId: $collaborativeId, useCaseIds: $useCaseIds) { - ... on TypeCollaborative { - id - useCases { - id - title - } - } - } - } -`); - -const UseCases = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const router = useRouter(); - const queryClient = useQueryClient(); - const COLLAB_USECASES_TOAST_ID = 'collaboratives-usecases-toast'; - - const [data, setData] = useState([]); - const [selectedRow, setSelectedRows] = useState([]); - - const CollaborativeDetails = - useQuery( - [`Collaborative_UseCase_Details`, params.id], - () => - GraphQL( - FetchCollaborativeDetails, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - useEffect(() => { - fetchData('usecase', '?size=1000&page=1') - .then((res: { results: SearchUseCase[] }) => { - setData(res.results); - }) - .catch((err) => { - console.error(err); - }); - }, []); - - const columns = [ - { accessorKey: 'title', header: 'Title' }, - { accessorKey: 'category', header: 'Sector' }, - { accessorKey: 'modified', header: 'Last Modified' }, - ]; - - const generateTableData = (list: SearchUseCase[]) => { - return list.map((item) => { - const sector = item.sectors?.[0]; - return { - title: item.title, - id: String(item.id), - category: typeof sector === 'string' ? sector : sector?.name || 'N/A', - modified: formatDate(item.modified ?? null) || '', - }; - }); - }; - - const rows = generateTableData(data); - const assignedUseCaseIds = new Set( - (CollaborativeDetails?.data?.collaboratives?.[0]?.useCases ?? []).map( - (item) => String(item.id) - ) - ); - const defaultSelectedRows = rows.filter((row) => - assignedUseCaseIds.has(row.id) - ); - - const { mutate } = useMutation( - () => - GraphQL( - AssignCollaborativeUseCases, - { - [params.entityType]: params.entitySlug, - }, - { - collaborativeId: params.id, - useCaseIds: Array.isArray(selectedRow) - ? selectedRow.map((row) => String(row.id)) - : [], - } - ), - { - onSuccess: () => { - toast('Use Cases Assigned Successfully', { - id: COLLAB_USECASES_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`Collaborative_UseCase_Details`, params.id], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - router.push( - `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/contributors` - ); - }, - onError: (err: unknown) => { - toast(`Received ${err} on use case assignment`, { - id: COLLAB_USECASES_TOAST_ID, - }); - }, - } - ); - - return ( - <> - {((CollaborativeDetails?.data?.collaboratives?.[0]?.useCases?.length ?? -1) >= 0) && - data.length > 0 && - !CollaborativeDetails.isLoading ? ( - <> -
-
- - Selected {selectedRow.length} of {data.length} - -
-
- -
-
- - { - setSelectedRows(Array.isArray(selected) ? selected : []); // Ensure selected is always an array - }} - /> - - ) : ( - - )} - - ); -}; - -export default UseCases; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/collaborative-summary.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/collaborative-summary.ts new file mode 100644 index 00000000..e5db94f5 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/collaborative-summary.ts @@ -0,0 +1,75 @@ +export type CollaborativeEditStep = 'about' | 'people' | 'content' | 'publish'; + +export function plainSummary(value?: string | null) { + return (value ?? '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function isCollaborativeAboutComplete(item: { + title?: string | null; + summary?: string | null; + sectors?: unknown[] | null; + sdgs?: unknown[] | null; +}) { + return ( + Boolean(item.title?.trim()) && + plainSummary(item.summary).length > 0 && + (item.sectors?.length ?? 0) > 0 && + (item.sdgs?.length ?? 0) > 0 + ); +} + +export function aboutEditTarget(item: { + title?: string | null; + summary?: string | null; + sectors?: unknown[] | null; + sdgs?: unknown[] | null; +}) { + if (!item.title?.trim()) return 'collaborative-name'; + if (!plainSummary(item.summary)) return 'description'; + if ((item.sectors?.length ?? 0) === 0) return 'sectors'; + if ((item.sdgs?.length ?? 0) === 0) return 'sdgs'; + return 'basic-information'; +} + +export function contentEditTarget(item: { + datasets?: unknown[] | null; + useCases?: unknown[] | null; +}) { + if ((item.datasets?.length ?? 0) === 0) return 'datasets'; + return 'use-cases'; +} + +export function isCollaborativeContentComplete(item: { + datasets?: unknown[] | null; + useCases?: unknown[] | null; +}) { + return (item.datasets?.length ?? 0) + (item.useCases?.length ?? 0) > 0; +} + +export function firstIncompleteCollaborativeEditStep(item: { + title?: string | null; + summary?: string | null; + datasets?: unknown[] | null; + useCases?: unknown[] | null; +}): CollaborativeEditStep { + if (!isCollaborativeAboutComplete(item)) return 'about'; + if (!isCollaborativeContentComplete(item)) return 'content'; + return 'publish'; +} + +export function errorMessage(error: unknown, fallback: string) { + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() + ) { + return error.message; + } + return fallback; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/components/EditLayout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/components/EditLayout.tsx new file mode 100644 index 00000000..9f266f93 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/components/EditLayout.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { ReactNode, useEffect } from 'react'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import { CollaborativeInputPartial } from '@/gql/generated/graphql'; +import { + IconClipboardCheck, + IconFileDescription, + IconShare, + IconUsers, +} from '@tabler/icons-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Stepper, toast, useStepperStep } from 'opub-ui'; +import type { StepperItem } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { + isCollaborativeAboutComplete, + isCollaborativeContentComplete, + errorMessage, +} from '../collaborative-summary'; +import { useCollaborativeEditStatus } from '../context'; +import styles from '../edit.module.scss'; +import { + FetchCollaborativeReview, + UpdateCollaborativeTitleMutation, +} from '../wizard-documents'; +import { WizardHeader } from './WizardHeader'; + +interface LayoutProps { + children?: ReactNode; + params: { id: string }; +} + +const STEP_BY_PATH: Record = { + about: 1, + people: 2, + content: 3, + publish: 4, +}; + +const PATH_BY_STEP: Record = { + 1: 'about', + 2: 'people', + 3: 'content', + 4: 'publish', +}; + +const layoutList = ['about', 'people', 'content', 'publish']; + +function StepErrorSync() { + const { showErrors } = useStepperStep(); + const { setStepShowErrors } = useCollaborativeEditStatus(); + + useEffect(() => { + setStepShowErrors(showErrors); + }, [showErrors, setStepShowErrors]); + + return null; +} + +function stepContent(isActive: boolean, children: ReactNode) { + if (!isActive) return null; + return ( + <> + + {children} + + ); +} + +export function EditLayout({ children, params }: LayoutProps) { + const pathName = usePathname(); + const router = useRouter(); + const routerParams = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + const queryClient = useQueryClient(); + + const summaryQuery = useQuery( + [`collaborative_wizard_${routerParams.id}`], + () => + GraphQL( + FetchCollaborativeReview, + { + [routerParams.entityType]: routerParams.entitySlug, + }, + { + filters: { + id: routerParams.id, + }, + } + ) + ); + + const pathItem = layoutList.find((item) => pathName.includes(`/${item}`)); + const currentStep = pathItem ? STEP_BY_PATH[pathItem] : 1; + const collaborative = summaryQuery.data?.collaboratives?.[0]; + const completionReady = summaryQuery.isFetched; + + const { + status, + setStatus, + runBeforeNavigateHandler, + aboutCompleted, + setAboutCompleted, + peopleCompleted, + setPeopleCompleted, + contentCompleted, + setContentCompleted, + } = useCollaborativeEditStatus(); + + if (pathItem === 'people' && !peopleCompleted) { + setPeopleCompleted(true); + } + if (pathItem === 'content' && !contentCompleted) { + setContentCompleted(true); + } + + useEffect(() => { + if (!collaborative) return; + if (pathItem !== 'about') { + setAboutCompleted(isCollaborativeAboutComplete(collaborative)); + } + if (pathItem !== 'people') { + const peopleCount = + (collaborative.contributors?.length ?? 0) + + (collaborative.partnerOrganizations?.length ?? 0) + + (collaborative.supportingOrganizations?.length ?? 0); + setPeopleCompleted(peopleCount > 0); + } + if (pathItem !== 'content') { + setContentCompleted(isCollaborativeContentComplete(collaborative)); + } + }, [ + collaborative, + pathItem, + setAboutCompleted, + setPeopleCompleted, + setContentCompleted, + ]); + + const { mutate: saveTitle } = useMutation( + (data: { data: CollaborativeInputPartial }) => + GraphQL( + UpdateCollaborativeTitleMutation, + { + [routerParams.entityType]: routerParams.entitySlug, + }, + data + ), + { + onMutate: () => setStatus('loading'), + onSuccess: () => { + void summaryQuery.refetch(); + void queryClient.invalidateQueries({ + queryKey: [`fetch_collaborative_about_${params.id}`], + }); + setStatus('success'); + }, + onError: (error: unknown) => { + setStatus('success'); + toast(errorMessage(error, 'Unable to update title right now.')); + }, + } + ); + + if (!pathItem) { + return <>{children}; + } + + const goBackURL = `/dashboard/${routerParams.entityType}/${routerParams.entitySlug}/collaboratives`; + const stepBase = `/dashboard/${routerParams.entityType}/${routerParams.entitySlug}/collaboratives/edit/${params.id}`; + + const handleStepClick = (step: number) => { + const nextPath = PATH_BY_STEP[step]; + if (!nextPath || nextPath === pathItem) return; + void runBeforeNavigateHandler().then(() => { + router.push(`${stepBase}/${nextPath}`); + }); + }; + + const steps: StepperItem[] = [ + { + step: 1, + label: 'About', + description: 'What is this?', + icon: IconFileDescription, + isCompleted: + aboutCompleted || (!completionReady && currentStep > 1), + content: stepContent(currentStep === 1, children), + }, + { + step: 2, + label: 'People', + description: 'Who is involved?', + icon: IconUsers, + isCompleted: + peopleCompleted || (!completionReady && currentStep > 2), + content: stepContent(currentStep === 2, children), + }, + { + step: 3, + label: 'Content', + description: 'What is connected?', + icon: IconShare, + isCompleted: + contentCompleted || (!completionReady && currentStep > 3), + content: stepContent(currentStep === 3, children), + }, + { + step: 4, + label: 'Review & Publish', + description: 'Check readiness', + icon: IconClipboardCheck, + isCompleted: aboutCompleted && contentCompleted, + content: stepContent(currentStep === 4, children), + }, + ]; + + return ( +
+ + saveTitle({ data: { id: params.id, title: nextTitle } }) + } + /> + +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/components/WizardHeader.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/components/WizardHeader.tsx new file mode 100644 index 00000000..c250702c --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/components/WizardHeader.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Button, Icon, Spinner, Text, TextField } from 'opub-ui'; + +import { Icons } from '@/components/icons'; + +interface WizardHeaderProps { + title: string; + goBackURL: string; + status: 'loading' | 'success'; + titlePending?: boolean; + onTitleSave?: (title: string) => void; +} + +export function WizardHeader({ + title, + goBackURL, + status, + titlePending = false, + onTitleSave, +}: WizardHeaderProps) { + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(title); + const [syncedTitle, setSyncedTitle] = useState(title); + if (title !== syncedTitle) { + setSyncedTitle(title); + setDraftTitle(title); + } + + const handleSave = () => { + const next = draftTitle.trim(); + if (next && next !== title) { + onTitleSave?.(next); + } + setEditing(false); + }; + + return ( +
+
+
+ + + + {editing ? ( +
+ + +
+ ) : titlePending ? ( +
+ ) : ( + <> + + {title || 'Untitled Collaborative'} + + {onTitleSave ? ( + + ) : null} + + )} +
+
+ {status === 'loading' ? ( + + ) : ( + + )} + + {status === 'loading' ? 'Saving…' : 'All changes saved'} + +
+
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/context.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/context.tsx index 864e36c8..56800b33 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/context.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/context.tsx @@ -1,27 +1,83 @@ 'use client'; -import { createContext, useContext, useState } from 'react'; + +import { createContext, useContext, useRef, useState } from 'react'; type StatusType = 'loading' | 'success'; +type BeforeNavigateHandler = (() => Promise | void) | null; -const EditStatusContext = createContext<{ +interface CollaborativeEditStatusContextValue { status: StatusType; setStatus: (status: StatusType) => void; -} | null>(null); + registerBeforeNavigateHandler: (handler: BeforeNavigateHandler) => void; + runBeforeNavigateHandler: () => Promise; + aboutCompleted: boolean; + setAboutCompleted: (completed: boolean) => void; + peopleCompleted: boolean; + setPeopleCompleted: (completed: boolean) => void; + contentCompleted: boolean; + setContentCompleted: (completed: boolean) => void; + stepShowErrors: boolean; + setStepShowErrors: (show: boolean) => void; +} + +const CollaborativeEditStatusContext = + createContext(null); -export const EditStatusProvider = ({ children }: { children: React.ReactNode }) => { +export const CollaborativeEditStatusProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { const [status, setStatus] = useState('success'); + const [aboutCompleted, setAboutCompleted] = useState(false); + const [peopleCompleted, setPeopleCompleted] = useState(false); + const [contentCompleted, setContentCompleted] = useState(false); + const [stepShowErrors, setStepShowErrors] = useState(false); + const beforeNavigateHandlerRef = useRef(null); + + const registerBeforeNavigateHandler = (handler: BeforeNavigateHandler) => { + beforeNavigateHandlerRef.current = handler; + }; + + const runBeforeNavigateHandler = async () => { + await beforeNavigateHandlerRef.current?.(); + }; return ( - + {children} - + ); }; -export const useEditStatus = () => { - const context = useContext(EditStatusContext); +export const EditStatusProvider = CollaborativeEditStatusProvider; + +export const useCollaborativeEditStatus = () => { + const context = useContext(CollaborativeEditStatusContext); if (!context) { - throw new Error('useEditStatus must be used within EditStatusProvider'); + throw new Error( + 'useCollaborativeEditStatus must be used within CollaborativeEditStatusProvider' + ); } return context; }; + +export const useEditStatus = () => { + const { status, setStatus } = useCollaborativeEditStatus(); + return { status, setStatus }; +}; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/edit.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/edit.module.scss new file mode 100644 index 00000000..d28a539a --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/edit.module.scss @@ -0,0 +1,66 @@ +.collaborativeStepper { + padding-left: var(--space-6); + padding-right: var(--space-6); + padding-top: var(--space-6); + --action-primary-basic-default: var(--blue-primary-color); + --action-primary-basic-hovered: var(--blue-primary-text); + --action-primary-basic-pressed: var(--blue-primary-text); + --text-highlight: var(--blue-primary-color); + + [class*='buttonNext'] { + background-color: var(--blue-primary-color) !important; + border-color: var(--blue-primary-color) !important; + color: var(--base-pure-white) !important; + --button-color: var(--blue-primary-color); + --button-color-hover: var(--blue-primary-text); + --button-color-active: var(--blue-primary-text); + } +} + +.thumbnailRow { + display: flex; + align-items: center; + gap: 12px; + border: 1px solid var(--base-gray-slate-solid-6); + border-radius: 8px; + padding: 10px 12px; +} + +.thumbnailPreview { + width: 60px; + height: 60px; + flex-shrink: 0; + border-radius: 6px; + object-fit: cover; +} + +.thumbnailMeta { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.thumbnailActions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 4px; +} + +.thumbnailInput { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + +.overflowVisible { + overflow: visible !important; + + :global([class*='SectionCard']), + :global([class*='sectionCard']) { + overflow: visible !important; + } +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/layout.tsx index 458d34f1..81c08b39 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/layout.tsx @@ -1,234 +1,7 @@ -'use client'; - -import { useParams, usePathname, useRouter } from 'next/navigation'; -import { graphql } from '@/gql'; -import { CollaborativeInputPartial } from '@/gql/generated/graphql'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Tab, TabList, Tabs, toast } from 'opub-ui'; - -import { GraphQL } from '@/lib/api'; -import StepNavigation from '../../components/StepNavigation'; -import TitleBar from '../../components/title-bar'; -import { EditStatusProvider, useEditStatus } from './context'; - -const UpdateCollaborativeTitleMutation = graphql(` - mutation updateCollaborativeTitle($data: CollaborativeInputPartial!) { - updateCollaborative(data: $data) { - __typename - id - title - } - } -`); - -const FetchCollaborativeTitle = graphql(` - query CollaborativeTitle($pk: ID!) { - collaborative(pk: $pk) { - id - title - } - } -`); - -const TabsAndChildren = ({ children }: { children: React.ReactNode }) => { - const router = useRouter(); - const pathName = usePathname(); - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const queryClient = useQueryClient(); - - const layoutList = [ - 'details', - 'contributors', - 'assign', - 'usecases', - 'publish', - ]; - - const pathItem = layoutList.find(function (v) { - return pathName.indexOf(v) >= 0; - }); - - const CollaborativeData = useQuery( - [`fetch_CollaborativeData_${params.id}`], - () => - GraphQL( - FetchCollaborativeTitle, - { - [params.entityType]: params.entitySlug, - }, - { - pk: params.id, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const COLLAB_EDIT_TOAST_ID = 'collaboratives-edit-toast'; - - const { mutate, isLoading: editMutationLoading } = useMutation( - (data: { data: CollaborativeInputPartial }) => - GraphQL( - UpdateCollaborativeTitleMutation, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: () => { - toast('Collaborative updated successfully', { - id: COLLAB_EDIT_TOAST_ID, - }); - queryClient.invalidateQueries({ - queryKey: [`fetch_CollaborativeData_${params.id}`], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeData_details`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_CollaborativeDetails`, - params.entityType, - params.entitySlug, - params.id, - ], - }); - }, - onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, { id: COLLAB_EDIT_TOAST_ID }); - }, - } - ); - - const links = [ - { - label: 'Collaborative Details', - url: `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/details`, - selected: pathItem === 'details', - }, - { - label: 'Datasets', - url: `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/assign`, - selected: pathItem === 'assign', - }, - { - label: 'Use Cases', - url: `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/usecases`, - selected: pathItem === 'usecases', - }, - { - label: 'Contributors', - url: `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/contributors`, - selected: pathItem === 'contributors', - }, - { - label: 'Publish', - url: `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives/edit/${params.id}/publish`, - selected: pathItem === 'publish', - }, - ]; - - const handleTabClick = (url: string) => { - router.replace(url); // Navigate to the selected tab - }; - - const initialTabLabel = - links.find((option) => option.selected)?.label || 'Collaborative Details'; - - const { status, setStatus } = useEditStatus(); - - // Safely extract collaborative title - now using direct collaborative object - const collaborativeTitle = - CollaborativeData?.data?.collaborative?.title || ''; - - // Show loading state while fetching - if (CollaborativeData.isLoading) { - return ( -
-
Loading collaborative data...
-
- ); - } - - // Show error state if query failed - if (CollaborativeData.error) { - console.error('Collaborative query error:', CollaborativeData.error); - return ( -
-
Error loading collaborative data
-
- {CollaborativeData.error instanceof Error - ? CollaborativeData.error.message - : 'Unknown error'} -
-
- Check console for details. ID: {params.id} -
-
- ); - } - - return ( -
- mutate({ data: { title: e, id: params.id.toString() } })} - loading={editMutationLoading} - status={status} - setStatus={setStatus} - /> - - handleTabClick( - links.find((link) => link.label === newValue)?.url || '' - ) - } - > - - {links.map((item, index) => ( - handleTabClick(item.url)} - className="uppercase" - > - {item.label} - - ))} - - -
{children}
-
- -
-
- ); -}; - -const EditCollaborative = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); -}; - -export default EditCollaborative; +export default function EditCollaborativeLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/wizard-documents.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/wizard-documents.ts new file mode 100644 index 00000000..f2f9d6df --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/wizard-documents.ts @@ -0,0 +1,375 @@ +import { graphql } from '@/gql'; + +export const UpdateCollaborativeMutation = graphql(` + mutation updateCollaborative($data: CollaborativeInputPartial!) { + updateCollaborative(data: $data) { + __typename + id + title + summary + created + modified + slug + status + startedOn + completedOn + platformUrl + logo { + name + path + url + } + coverImage { + name + path + url + } + } + } +`); + +export const FetchCollaborative = graphql(` + query CollaborativeData($filters: CollaborativeFilter) { + collaboratives(filters: $filters) { + id + title + summary + platformUrl + logo { + name + path + url + } + coverImage { + name + path + url + } + status + slug + startedOn + completedOn + } + } +`); + +export const UpdateCollaborativeTitleMutation = graphql(` + mutation updateCollaborativeTitle($data: CollaborativeInputPartial!) { + updateCollaborative(data: $data) { + __typename + id + title + } + } +`); + +export const FetchCollaborativeTitle = graphql(` + query CollaborativeTitle($pk: ID!) { + collaborative(pk: $pk) { + id + title + } + } +`); + +export const FetchCollaborativeMetadata = graphql(` + query CollaborativeMetadata($filters: CollaborativeFilter) { + collaboratives(filters: $filters) { + id + metadata { + metadataItem { + id + label + dataType + } + id + value + } + tags { + id + value + } + sectors { + id + name + } + sdgs { + id + code + name + number + } + geographies { + id + name + code + type + } + } + } +`); + +export const metadataQueryDoc = graphql(` + query CollaborativeMetaDataList($filters: MetadataFilter) { + metadata(filters: $filters) { + id + label + dataStandard + urn + dataType + options + filterable + } + } +`); + +export const sectorsListQueryDoc = graphql(` + query SectorList { + sectors { + id + name + } + } +`); + +export const sdgsListQueryDoc = graphql(` + query SDGList { + sdgs { + id + code + name + number + } + } +`); + +export const tagsListQueryDoc = graphql(` + query TagsList { + tags { + id + value + } + } +`); + +export const geographiesListQueryDoc = graphql(` + query GeographiesList { + geographies { + id + name + code + type + parentId { + id + name + } + } + } +`); + +export const UpdateCollaborativeMetadata = graphql(` + mutation addUpdateCollaborativeMetadata($updateMetadataInput: UpdateCollaborativeMetadataInput!) { + addUpdateCollaborativeMetadata(updateMetadataInput: $updateMetadataInput) { + __typename + ... on TypeCollaborative { + id + metadata { + metadataItem { + id + label + dataType + } + id + value + } + tags { + id + value + } + sectors { + id + name + } + sdgs { + id + code + name + number + } + geographies { + id + name + code + type + } + } + } + } +`); + +export const FetchCollaborativeDatasets = graphql(` + query CollaborativeDetails($filters: CollaborativeFilter) { + collaboratives(filters: $filters) { + id + title + datasets { + id + title + modified + sectors { + name + } + } + } + } +`); + +export const AssignCollaborativeDatasets = graphql(` + mutation assignCollaborativeDatasets($collaborativeId: String!, $datasetIds: [UUID!]!) { + updateCollaborativeDatasets(collaborativeId: $collaborativeId, datasetIds: $datasetIds) { + ... on TypeCollaborative { + id + datasets { + id + title + } + } + } + } +`); + +export const FetchCollaborativeUseCases = graphql(` + query CollaborativeUseCaseDetails($filters: CollaborativeFilter) { + collaboratives(filters: $filters) { + id + title + useCases { + id + title + slug + modified + sectors { + name + } + } + } + } +`); + +export const AssignCollaborativeUseCases = graphql(` + mutation assignCollaborativeUseCases($collaborativeId: String!, $useCaseIds: [String!]!) { + updateCollaborativeUseCases(collaborativeId: $collaborativeId, useCaseIds: $useCaseIds) { + ... on TypeCollaborative { + id + useCases { + id + title + } + } + } + } +`); + +export const FetchCollaborativeReview = graphql(` + query CollabDetails($filters: CollaborativeFilter) { + collaboratives(filters: $filters) { + id + title + summary + website + platformUrl + metadata { + metadataItem { + id + label + dataType + } + id + value + } + sectors { + id + name + } + sdgs { + id + code + name + } + tags { + id + value + } + startedOn + completedOn + logo { + name + path + url + } + coverImage { + name + path + url + } + datasets { + title + id + description + status + sectors { + name + } + modified + } + useCases { + title + id + slug + sectors { + name + } + modified + } + contactEmail + status + slug + contributors { + id + fullName + username + profilePicture { + url + } + } + supportingOrganizations { + id + name + logo { + url + name + } + } + partnerOrganizations { + id + name + logo { + url + name + } + } + } + } +`); + +export const publishCollaborativeMutation = graphql(` + mutation publishCollaborative($collaborativeId: String!) { + publishCollaborative(collaborativeId: $collaborativeId) { + ... on TypeCollaborative { + id + status + } + } + } +`); + diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx index ca1aa781..79c01bdf 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx @@ -14,8 +14,8 @@ import { LinkButton } from '@/components/Link'; import { Loading } from '@/components/loading'; import { GraphQL } from '@/lib/api'; import { formatDate } from '@/lib/utils'; -import { ActionBar } from '../dataset/components/action-bar'; -import { Navigation } from '../dataset/components/navigate-org-datasets'; +import { ActionBar } from '../provider-flow/ActionBar'; +import { Navigation } from '../provider-flow/Navigation'; interface CollaborativeListItem { id: string; @@ -149,7 +149,7 @@ export default function CollaborativePage() { const created = response.addCollaborative; const createdId = 'id' in created ? created.id : undefined; router.push( - `/dashboard/${entityType}/${entitySlug}/collaboratives/edit/${createdId}/details` + `/dashboard/${entityType}/${entitySlug}/collaboratives/edit/${createdId}` ); AllCollaboratives.refetch(); } @@ -212,7 +212,7 @@ export default function CollaborativePage() { {row.original.title} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/access/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/access/page.tsx deleted file mode 100644 index 15b32c42..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/access/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client'; - -import { - parseAsBoolean, - parseAsString, - useQueryState, -} from 'nuqs'; - -import AccessModelForm from '../components/AccessModelForm'; -import AccessModelList from '../components/AccessModelList'; - -const Access = () => { - const [list, setList] = useQueryState( - 'list', - parseAsBoolean.withDefault(true) // Default value set to true boolean - ); - - const [accessModelId, setAccessModelId] = useQueryState('id', parseAsString); - - return ( -
- {list ? ( - - ) : ( - - )} -
- ); -}; - -export default Access; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/AccessModelForm.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/AccessModelForm.tsx deleted file mode 100644 index 3e8d25ba..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/AccessModelForm.tsx +++ /dev/null @@ -1,701 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { graphql } from '@/gql'; -import { AccessTypes, EditAccessModelInput } from '@/gql/generated/graphql'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import { - Button, - Combobox, - Divider, - Icon, - Select, - Sheet, - Spinner, - Text, - TextField, - toast, -} from 'opub-ui'; - -import { GraphQL } from '@/lib/api'; -import { cn } from '@/lib/utils'; -import { Icons } from '@/components/icons'; -import styles from '../edit.module.scss'; -import ResourceSelector from './ResourceSelector'; - -interface AccessModelProps { - setList: (list: boolean) => void; - setAccessModelId: (id: string | null) => void; - accessModelId: string | null; -} - -interface SchemaField { - id?: string | number; - fieldName?: string; - label?: string; - value?: string; -} - -interface ComboResource { - label: string; - value: string; - schema?: SchemaField[]; -} - -interface AccessModelResource { - resource: string; - fields: number[]; -} - -interface AccessModelFormData { - dataset: string; - name: string; - description: string; - type: string; - resources: AccessModelResource[]; - accessModelId: string; -} - -interface ModelResourceField { - id: string | number; - fieldName: string; -} - -interface ModelResource { - resource: { id: string; name: string }; - fields: ModelResourceField[]; -} - -function mapDatasetResourcesToCombo( - resources: Array<{ - name: string; - id: string; - schema?: SchemaField[] | null; - }> -): ComboResource[] { - return resources.map((field) => ({ - label: field.name, - value: field.id, - schema: field.schema ?? undefined, - })); -} - -function mapAccessModelToFormState( - accessModel: object, - accessModelId: string, - datasetId: string -): { - formData: AccessModelFormData; - selectedResources: ComboResource[]; - selectedFields: ComboResource[]; -} { - const name = - 'name' in accessModel && typeof accessModel.name === 'string' - ? accessModel.name - : ''; - const description = - 'description' in accessModel && typeof accessModel.description === 'string' - ? accessModel.description - : ''; - const type = - 'type' in accessModel && typeof accessModel.type === 'string' - ? accessModel.type - : 'PUBLIC'; - const modelResources = getModelResources(accessModel); - - const selectedResources = modelResources.map((resource) => ({ - label: resource.resource.name, - value: resource.resource.id, - schema: resource.fields.map((field) => ({ - label: field.fieldName, - value: String(field.id), - })), - })); - - return { - formData: { - dataset: datasetId, - name: name ?? '', - description: description ?? '', - type: type ?? 'PUBLIC', - accessModelId, - resources: modelResources.map((resource) => ({ - resource: resource.resource.id, - fields: resource.fields.map((field) => +field.id), - })), - }, - selectedResources, - selectedFields: selectedResources.map((resource) => ({ - label: resource.label, - value: resource.value, - schema: (resource.schema ?? []).map((field) => ({ - id: field.value, - fieldName: field.label, - })), - })), - }; -} - -function getModelResources(accessModel: object): ModelResource[] { - if ( - !('modelResources' in accessModel) || - !Array.isArray(accessModel.modelResources) - ) { - return []; - } - - return accessModel.modelResources.flatMap((resource) => { - if (typeof resource !== 'object' || resource === null) { - return []; - } - if (!('resource' in resource) || !('fields' in resource)) { - return []; - } - const nested = resource.resource; - if ( - typeof nested !== 'object' || - nested === null || - !('id' in nested) || - !('name' in nested) || - !Array.isArray(resource.fields) - ) { - return []; - } - - return [ - { - resource: { - id: String(nested.id), - name: String(nested.name), - }, - fields: resource.fields.flatMap((field: unknown) => { - if (typeof field !== 'object' || field === null) { - return []; - } - if (!('id' in field) || !('fieldName' in field)) { - return []; - } - const fieldId = field.id; - return [ - { - id: - typeof fieldId === 'string' || typeof fieldId === 'number' - ? fieldId - : String(fieldId), - fieldName: String(field.fieldName), - }, - ]; - }), - }, - ]; - }); -} - -const datasetResourcesQuery = graphql(` - query resources($datasetId: UUID!) { - datasetResources(datasetId: $datasetId) { - id - type - name - description - schema { - id - fieldName - } - } - } -`); - -const accessModelListQuery = graphql(` - query accessModelResources($datasetId: UUID!) { - accessModelResources(datasetId: $datasetId) { - id - name - description - type - created - modified - } - } -`); - -const editaccessModel = graphql(` - mutation editAccessModel($accessModelInput: EditAccessModelInput!) { - editAccessModel(accessModelInput: $accessModelInput) { - __typename - ... on TypeAccessModel { - id - description - name - type - } - } - } -`); - -const getAccessModelDetails = graphql(` - query accessModel($accessModelId: UUID!) { - accessModel(accessModelId: $accessModelId) { - resourceFields { - fields - resource { - id - name - } - } - id - name - type - description - created - modified - } - } -`); - -const AccessModelForm: React.FC = ({ - setList, - setAccessModelId, - accessModelId, -}) => { - const ACCESS_MODEL_SAVE_ERROR_TOAST_ID = 'dataset-access-model-save-error'; - const getErrorMessage = (err: unknown, fallback: string) => - typeof err === 'object' && - err !== null && - 'message' in err && - typeof err.message === 'string' && - err.message.trim() - ? err.message.trim() - : fallback; - - useEffect(() => { - setList(false); - }, [setList]); - - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const { data, isLoading } = useQuery( - [`resourcesList_${params.id}`], - () => - GraphQL( - datasetResourcesQuery, - { - [params.entityType]: params.entitySlug, - }, - { datasetId: params.id } - ) - ); - - const { - data: accessModelList, - refetch: accessModelListRefetch, - } = useQuery( - [`accessModelList_${params.id}`], - () => - GraphQL( - accessModelListQuery, - { - [params.entityType]: params.entitySlug, - }, - { datasetId: params.id } - ) - ); - const { - data: accessModelDetails, - refetch: accessModelDetailsRefetch, - } = useQuery( - [`accessModelDetails${params.id}`], - () => - GraphQL( - getAccessModelDetails, - { - [params.entityType]: params.entitySlug, - }, - { accessModelId: accessModelId } - ) - ); - - const [accessModelData, setAccessModelData] = useState({ - dataset: params.id, - name: '', - description: '', - type: 'PUBLIC', - resources: [], - accessModelId: '', - }); - const [previousAccessModelData, setPreviousAccessModelData] = - useState(accessModelData); - - const [selectedResources, setSelectedResources] = useState( - [] - ); - const [, setShowSelectAll] = useState(false); - - const [availableResources, setAvailableResources] = useState( - [] - ); - - const [selectedFields, setSelectedFields] = useState([]); - const [prevDatasetResources, setPrevDatasetResources] = useState(data); - if (data !== prevDatasetResources) { - setPrevDatasetResources(data); - if (data) { - setAvailableResources( - mapDatasetResourcesToCombo(data.datasetResources) - ); - } - } - - const [prevAccessModelDetails, setPrevAccessModelDetails] = - useState(accessModelDetails); - const [prevAccessModelId, setPrevAccessModelId] = useState(accessModelId); - if ( - accessModelDetails !== prevAccessModelDetails || - accessModelId !== prevAccessModelId - ) { - setPrevAccessModelDetails(accessModelDetails); - setPrevAccessModelId(accessModelId); - if (accessModelDetails?.accessModel && accessModelId) { - const mapped = mapAccessModelToFormState( - accessModelDetails.accessModel, - accessModelId, - params.id - ); - setAccessModelData(mapped.formData); - setPreviousAccessModelData(mapped.formData); - setSelectedResources(mapped.selectedResources); - setSelectedFields(mapped.selectedFields); - } - } - - useEffect(() => { - if (accessModelDetails && accessModelDetails.accessModel && accessModelId) { - accessModelDetailsRefetch(); - } - }, [accessModelDetails, accessModelId, accessModelDetailsRefetch]); - - const handleAddResource = (resourceDetails: ComboResource[]) => { - setSelectedResources(resourceDetails); - setAvailableResources(resourceDetails); - setSelectedFields(resourceDetails); - const newResources = resourceDetails.map((resource) => ({ - resource: resource.value, - fields: (resource.schema ?? []).map((field) => +String(field.id ?? 0)), - })); - - setAccessModelData((prevData) => ({ - ...prevData, - resources: newResources, - })); - - if (resourceDetails.length === 0) { - setAccessModelData((prevData) => ({ - ...prevData, - resources: [], - })); - } else { - setAccessModelData((prevData) => ({ - ...prevData, - resources: [...prevData.resources], - })); - } - - handleSave({ ...accessModelData, resources: newResources }); - }; - - const handleRemoveResource = (resourceId: string) => { - setSelectedResources((prevResources) => - prevResources.filter((resource) => resource.value !== resourceId) - ); - - setSelectedFields((prevFields) => - prevFields.filter((field) => field.value.split('.')[0] !== resourceId) - ); - - const updatedResources = accessModelData.resources.filter( - (resource) => resource.resource !== resourceId - ); - - setAccessModelData((prevData) => ({ - ...prevData, - resources: updatedResources, - })); - - handleSave({ ...accessModelData, resources: updatedResources }); - }; - const handleSelectAll = () => { - const allResources = - data?.datasetResources.map((resource) => ({ - label: resource.name, - value: resource.id, - schema: resource.schema.map((field) => ({ - label: field.fieldName, - value: field.id.toString(), - })), - })) || []; - - setSelectedFields(allResources); - setSelectedResources(allResources); - setShowSelectAll(false); - - const updatedResources = allResources.map((resource) => ({ - resource: resource.value, - fields: resource.schema.map((option) => parseInt(option.value, 10)), - })); - - const updatedData = { - ...accessModelData, - resources: updatedResources, - }; - - setAccessModelData(updatedData); - handleSave(updatedData); - }; - - const { mutate, isLoading: editMutationLoading } = useMutation( - (variables: { accessModelInput: EditAccessModelInput }) => - GraphQL( - editaccessModel, - { - [params.entityType]: params.entitySlug, - }, - variables - ), - { - onSuccess: (res) => { - accessModelDetailsRefetch(); - accessModelListRefetch(); - const edited = res?.editAccessModel; - if (edited && 'id' in edited) { - setAccessModelId(edited.id); - } - setPreviousAccessModelData(accessModelData); - }, - onError: (err: unknown) => { - toast( - `Error: ${getErrorMessage(err, 'Unable to save access model right now.')}`, - { id: ACCESS_MODEL_SAVE_ERROR_TOAST_ID } - ); - }, - } - ); - - const [isSheetOpen, setIsSheetOpen] = useState(false); - - const handleSave = (updatedData: AccessModelFormData) => { - if ( - JSON.stringify(updatedData) !== JSON.stringify(previousAccessModelData) - ) { - mutate({ - accessModelInput: { - name: updatedData.name, - dataset: params.id, - description: updatedData.description, - type: updatedData.type as AccessTypes, - resources: updatedData.resources, - accessModelId: accessModelId || null, - }, - }); - } - }; - - const handleChange = (field: string, value: string) => { - const updatedData = { ...accessModelData, [field]: value }; - setAccessModelData(updatedData); - }; - - return ( -
-
- - - - - - -
-
- Select Resource -
- - - -
-
- {accessModelList?.accessModelResources.map( - (item, index) => ( -
- -
- ) - )} -
-
-
-
- - {isLoading ? ( -
- -
- ) : ( -
-
- Auto Save - {editMutationLoading ? ( - - ) : ( - - )} -
-
-
-
- handleChange('name', e)} - onBlur={() => handleSave(accessModelData)} - label="Access Type Name" - name="name" - required - requiredIndicator={true} - helpText="To know about best practices for naming Resources go to our User Guide" - /> -
- handleChange(metadataFormItem.id, e)} - onBlur={() => handleSave(formData)} // Save on blur + onBlur={() => handleSave(formData)} />
); @@ -679,6 +891,7 @@ export function EditMetadata({ id }: { id: string }) { return (
({ label: option, @@ -704,6 +917,7 @@ export function EditMetadata({ id }: { id: string }) { return (
({ @@ -728,7 +942,7 @@ export function EditMetadata({ id }: { id: string }) { if (metadataFormItem.dataType === 'URL') { return (
- handleChange(metadataFormItem.id, e)} - onBlur={() => handleSave(formData)} // Save on blur + onBlur={() => handleSave(formData)} />
); @@ -746,7 +960,7 @@ export function EditMetadata({ id }: { id: string }) { if (metadataFormItem.dataType === 'DATE') { return (
- handleChange(metadataFormItem.id, e)} - onBlur={() => handleSave(formData)} // Save on blur + onBlur={() => handleSave(formData)} />
); @@ -790,28 +1004,157 @@ export function EditMetadata({ id }: { id: string }) { ]; useEffect(() => { - setStatus(updateMetadataMutation.isLoading ? 'loading' : 'success'); // update based on mutation state - }, [updateMetadataMutation.isLoading, setStatus]); + setStatus( + updateMetadataMutation.isLoading || updateDatasetTitleMutation.isLoading + ? 'loading' + : 'success' + ); + }, [ + updateMetadataMutation.isLoading, + updateDatasetTitleMutation.isLoading, + setStatus, + ]); + + const isPromptDataset = + getDatasetMetadata.data?.datasets?.[0]?.datasetType === 'PROMPT'; + const promptMetadataComplete = + !isPromptDataset || + (!isSelectionMissing(promptMetadataState.taskType) && + !isSelectionMissing(promptMetadataState.domain) && + !isSelectionMissing(promptMetadataState.targetLanguages) && + !isSelectionMissing(promptMetadataState.targetModelTypes)); + + useEffect(() => { + setMetadataCompleted( + Boolean(formData.title?.trim()) && + !isRichTextEmpty(formData.description) && + asOptionItems(formData.sectors).length > 0 && + asOptionItems(formData.tags).length > 0 && + Boolean(formData.license) && + promptMetadataComplete + ); + }, [ + formData.title, + formData.description, + formData.sectors, + formData.tags, + formData.license, + promptMetadataComplete, + setMetadataCompleted, + ]); + + const saveTitle = (title: string) => { + const trimmed = title.trim(); + if (!trimmed || trimmed === getDatasetMetadata.data?.datasets?.[0]?.title) { + return; + } + updateDatasetTitleMutation.mutate({ + updateDatasetInput: { + dataset: params.id, + title: trimmed, + }, + }); + }; + + const metadataFields = getMetaDataListQuery?.data?.metadata ?? []; + const sourceUrlFields = metadataFields.filter( + (item) => item.dataType === 'URL' + ); + const sourceDateFields = metadataFields.filter( + (item) => item.dataType === 'DATE' + ); + const additionalMetadataFields = metadataFields.filter( + (item) => item.dataType !== 'URL' && item.dataType !== 'DATE' + ); + + const metadataReady = + !getTagsList?.isLoading && + !getSectorsList?.isLoading && + !getGeographiesList?.isLoading && + !getDatasetMetadata.isLoading; + + useEffect(() => { + if (!metadataReady) return; + const hash = window.location.hash.slice(1); + if (!hash) return; + document.getElementById(hash)?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }, [metadataReady]); + + const sectorsError = + stepShowErrors && asOptionItems(formData.sectors).length === 0 + ? 'Sector is required' + : undefined; + const tagsError = + stepShowErrors && asOptionItems(formData.tags).length === 0 + ? 'Tags are required' + : undefined; + const licenseError = + stepShowErrors && !formData.license ? 'License is required' : undefined; + + const taskTypeError = + stepShowErrors && + isPromptDataset && + isSelectionMissing(promptMetadataState.taskType) + ? 'Task Type is required' + : undefined; + const domainError = + stepShowErrors && + isPromptDataset && + isSelectionMissing(promptMetadataState.domain) + ? 'Domain is required' + : undefined; + const targetLanguagesError = + stepShowErrors && + isPromptDataset && + isSelectionMissing(promptMetadataState.targetLanguages) + ? 'Target Languages are required' + : undefined; + const targetModelTypesError = + stepShowErrors && + isPromptDataset && + isSelectionMissing(promptMetadataState.targetModelTypes) + ? 'Target Model Types are required' + : undefined; return ( <> - {!getTagsList?.isLoading && - !getSectorsList?.isLoading && - !getGeographiesList?.isLoading && - !getDatasetMetadata.isLoading ? ( -
- <> - -
-
+ {metadataReady ? ( + + +
+
+ +
+ handleChange('title', value)} + onBlur={() => saveTitle(formData.title)} + />
- +
+
+ + +
{ return { label: item.name, value: item.id }; }) || [] } name="sectors" + selectedValue={formData.sectors} + error={sectorsError} onChange={(value) => { const next = Array.isArray(value) ? value : []; handleChange('sectors', next); @@ -840,80 +1199,147 @@ export function EditMetadata({ id }: { id: string }) { }} /> ({ - label: item.value, + getGeographiesList?.data?.geographies?.map((item) => ({ + label: `${item.name}${item.parentId ? ` (${item.parentId.name})` : ''}`, value: item.id, })) || [] } - key={`tags-${getTagsList.data?.tags?.length}`} // forces remount on change - label="Tags" - requiredIndicator - creatable + selectedValue={formData.geographies} onChange={(value) => { - setIsTagsListUpdated(true); const next = Array.isArray(value) ? value : []; - handleChange('tags', next); - handleSave({ ...formData, tags: next }); + handleChange('geographies', next); + handleSave({ ...formData, geographies: next }); }} /> ({ - label: `${item.name}${item.parentId ? ` (${item.parentId.name})` : ''}`, + getTagsList.data?.tags?.map((item) => ({ + label: item.value, value: item.id, })) || [] } - selectedValue={formData.geographies} + key={`tags-${getTagsList.data?.tags?.length}`} + label="Tags" + requiredIndicator + creatable + selectedValue={formData.tags} + error={tagsError} onChange={(value) => { + setIsTagsListUpdated(true); const next = Array.isArray(value) ? value : []; - handleChange('geographies', next); - handleSave({ ...formData, geographies: next }); + handleChange('tags', next); + handleSave({ ...formData, tags: next }); }} />
-
- {getMetaDataListQuery?.data?.metadata - ?.filter((item) => item.dataType === 'MULTISELECT') - .map((item) => ( -
{renderInputField(item)}
- ))} + + + {sourceUrlFields.length > 0 || sourceDateFields.length > 0 ? ( +
- {getMetaDataListQuery?.data?.metadata - ?.filter((item) => item.dataType !== 'MULTISELECT') - .map((item) => renderInputField(item))} + {sourceUrlFields.map((item) => renderInputField(item))} + {sourceDateFields.map((item) => renderInputField(item))}
-
+
+ ) : null} + + {additionalMetadataFields.length > 0 ? ( + +
+ {additionalMetadataFields + .filter((item) => item.dataType === 'MULTISELECT') + .map((item) => ( +
{renderInputField(item)}
+ ))} +
+ {additionalMetadataFields + .filter((item) => item.dataType !== 'MULTISELECT') + .map((item) => renderInputField(item))} +
+
+
+ ) : null} - {/* Prompt-specific metadata fields - only shown for PROMPT type datasets */} - {getDatasetMetadata.data?.datasets?.[0]?.datasetType === - 'PROMPT' && ( -
- - Prompt Dataset Metadata - - - Additional metadata specific to prompt datasets for AI/ML - use cases. - +
+ +
+ { + handleChange('accessType', selected); + handleChange('isPublic', selected === 'PUBLIC'); + }} + > + + Open Access + + + Restricted Access + + + ({ - label: item.label, - value: item.value, - }))} - className="w-full lg:w-2/4" - label="License" - value={formData?.license ? formData?.license : ''} - onChange={(value) => { - handleChange('license', value); - handleSave({ ...formData, license: value }); // Save on change - }} - /> +
- - + ) : null} +
) : ( diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/ResourceSelector.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/ResourceSelector.tsx deleted file mode 100644 index 6661cd38..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/ResourceSelector.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import React, { useState } from 'react'; -import { Button, Checkbox, Combobox, Icon, Text, TextField } from 'opub-ui'; - -import { cn } from '@/lib/utils'; -import { Icons } from '@/components/icons'; -import styles from '../edit.module.scss'; - -interface SchemaField { - id: string | number; - fieldName: string; -} - -interface SelectedResource { - id: string; - name: string; - schema: SchemaField[]; -} - -interface AccessModelResource { - resource: string; - fields: number[]; -} - -interface AccessModelData { - dataset: string; - name: string; - description: string; - type: string; - resources: AccessModelResource[]; - accessModelId: string; -} - -interface OptionItem { - label: string; - value: string; -} - -interface ResourceSelectorProps { - selectedResource: SelectedResource; - handleRemoveResource: (resourceId: string) => void; - accessModelData: AccessModelData; - setAccessModelData: (data: AccessModelData) => void; - handleSave: (updatedData: AccessModelData) => void; -} - -function schemaToOptions(schema: SchemaField[]): OptionItem[] { - return schema.map((field) => ({ - label: field.fieldName, - value: field.id.toString(), - })); -} - -function fieldsToOptions( - fieldIds: number[], - schema: SchemaField[] -): OptionItem[] { - return fieldIds.flatMap((fieldId) => { - const field = schema.find( - (schemaField) => schemaField.id.toString() === fieldId.toString() - ); - return field - ? [ - { - label: field.fieldName, - value: field.id.toString(), - }, - ] - : []; - }); -} - -const ResourceSelector: React.FC = ({ - selectedResource, - handleRemoveResource, - accessModelData, - setAccessModelData, - handleSave, -}) => { - const [selectAllFields, setSelectAllFields] = useState(true); - const [options, setOptions] = useState([]); - const [selectedFields, setSelectedFields] = useState([]); - const [prevSelectedResource, setPrevSelectedResource] = - useState(selectedResource); - const [prevAccessModelResources, setPrevAccessModelResources] = useState( - accessModelData.resources - ); - if ( - selectedResource !== prevSelectedResource || - accessModelData.resources !== prevAccessModelResources - ) { - setPrevSelectedResource(selectedResource); - setPrevAccessModelResources(accessModelData.resources); - - const initialOptions = schemaToOptions(selectedResource.schema); - setOptions(initialOptions); - - const selectedResourceData = accessModelData.resources.find( - (resource) => resource.resource === selectedResource.id - ); - - if (selectedResourceData) { - const initialSelectedFields = fieldsToOptions( - selectedResourceData.fields, - selectedResource.schema - ); - setSelectedFields(initialSelectedFields); - setSelectAllFields( - initialSelectedFields.length === initialOptions.length - ); - } else if (selectAllFields) { - setSelectedFields(initialOptions); - } - } - - const handleFieldSelection = (selectedOptions: OptionItem[]) => { - const updatedFields = selectedOptions.map((option) => ({ - label: option.label, - value: option.value, - })); - - setSelectedFields(updatedFields); - - const updatedData = { - ...accessModelData, - resources: [ - ...accessModelData.resources.filter( - (resource) => resource.resource !== selectedResource.id - ), - { - resource: selectedResource.id, - fields: updatedFields.map((field) => parseInt(field.value, 10)), - }, - ], - }; - - setAccessModelData(updatedData); - handleSave(updatedData); - - setSelectAllFields(updatedFields.length === options.length); - }; - - const handleSelectAllFields = () => { - const updatedFields = selectAllFields ? [] : options; - setSelectAllFields(!selectAllFields); - setSelectedFields(updatedFields); - - const updatedData = { - ...accessModelData, - resources: [ - ...accessModelData.resources.filter( - (resource) => resource.resource !== selectedResource.id - ), - { - resource: selectedResource.id, - fields: updatedFields.map((field) => parseInt(field.value, 10)), - }, - ], - }; - - setAccessModelData(updatedData); - handleSave(updatedData); - }; - - return ( -
-
- {selectedResource.name} - -
-
-
-
-
- { - if (Array.isArray(value)) { - handleFieldSelection(value); - } - }} - /> -
-
- - Select All - -
-
-
-
-
-
-
- Select Rows of the Resource - console.log(checked)} - > - Select All - -
-
- - -
-
-
-
-
- ); -}; - -export default ResourceSelector; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/context.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/context.tsx index b048b420..71660f8b 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/context.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/context.tsx @@ -1,35 +1,36 @@ 'use client'; -import { createContext, useContext, useRef, useState } from 'react'; -type StatusType = 'loading' | 'success'; -type BeforeNavigateHandler = (() => Promise | void) | null; +import { createContext, useContext, useState } from 'react'; -const DatasetEditStatusContext = createContext<{ - status: StatusType; - setStatus: (status: StatusType) => void; - registerBeforeNavigateHandler: (handler: BeforeNavigateHandler) => void; - runBeforeNavigateHandler: () => Promise; -} | null>(null); +import { useProviderEditState } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status'; -export const DatasetEditStatusProvider = ({ children }: { children: React.ReactNode }) => { - const [status, setStatus] = useState('success'); - const beforeNavigateHandlerRef = useRef(null); +type DatasetEditStatusContextValue = ReturnType & { + filesCompleted: boolean; + setFilesCompleted: (completed: boolean) => void; + metadataCompleted: boolean; + setMetadataCompleted: (completed: boolean) => void; +}; - const registerBeforeNavigateHandler = (handler: BeforeNavigateHandler) => { - beforeNavigateHandlerRef.current = handler; - }; +const DatasetEditStatusContext = + createContext(null); - const runBeforeNavigateHandler = async () => { - await beforeNavigateHandlerRef.current?.(); - }; +export const DatasetEditStatusProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const editState = useProviderEditState(); + const [filesCompleted, setFilesCompleted] = useState(false); + const [metadataCompleted, setMetadataCompleted] = useState(false); return ( {children} @@ -40,7 +41,9 @@ export const DatasetEditStatusProvider = ({ children }: { children: React.ReactN export const useDatasetEditStatus = () => { const context = useContext(DatasetEditStatusContext); if (!context) { - throw new Error('useDatasetEditStatus must be used within DatasetEditStatusProvider'); + throw new Error( + 'useDatasetEditStatus must be used within DatasetEditStatusProvider' + ); } return context; }; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/dataset-summary.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/dataset-summary.ts new file mode 100644 index 00000000..33787bb9 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/dataset-summary.ts @@ -0,0 +1,70 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { graphql } from '@/gql'; + +import { isRichTextEmpty } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/rich-text'; + +export function datasetReviewQueryKey(datasetId: string) { + return [`summary_${datasetId}`]; +} + +export function invalidateDatasetReview( + queryClient: QueryClient, + datasetId: string +) { + void queryClient.invalidateQueries({ + queryKey: datasetReviewQueryKey(datasetId), + }); +} + +export const datasetSummaryQueryDoc = graphql(` + query datasetTitleQuery($filters: DatasetFilter) { + datasets(filters: $filters) { + id + title + created + datasetType + description + license + tags { + id + } + sectors { + id + } + resources { + id + } + } + } +`); + +export type DatasetEditStep = 'resources' | 'metadata' | 'publish'; + +export function isDatasetMetadataComplete(dataset: { + title?: string | null; + description?: string | null; + license?: string | null; + tags?: unknown[] | null; + sectors?: unknown[] | null; +}): boolean { + return ( + Boolean(dataset.title?.trim()) && + !isRichTextEmpty(dataset.description) && + (dataset.sectors?.length ?? 0) > 0 && + (dataset.tags?.length ?? 0) > 0 && + Boolean(dataset.license) + ); +} + +export function firstIncompleteDatasetEditStep(dataset: { + title?: string | null; + description?: string | null; + license?: string | null; + tags?: unknown[] | null; + sectors?: unknown[] | null; + resources?: unknown[] | null; +}): DatasetEditStep { + if ((dataset.resources?.length ?? 0) === 0) return 'resources'; + if (!isDatasetMetadataComplete(dataset)) return 'metadata'; + return 'publish'; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/edit.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/edit.module.scss index 0cdeabef..2fa123ef 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/edit.module.scss +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/edit.module.scss @@ -1,17 +1,32 @@ +:global([class*='Select-module_PopoverWrapper']), +:global([class*='Select-module_Popover']) { + z-index: calc(var(--z-max) + 1); + pointer-events: auto; +} + .EditPage { display: flex; flex-direction: column; height: 100%; } -.accessModelEdit { - input { - width: 140px; - } -} +.datasetStepper { + padding-left: var(--space-6); + padding-right: var(--space-6); + padding-top: var(--space-6); + --action-primary-basic-default: var(--blue-primary-color); + --action-primary-basic-hovered: var(--blue-primary-text); + --action-primary-basic-pressed: var(--blue-primary-text); + --text-highlight: var(--blue-primary-color); -.combobox { - [role='presentation'] { - z-index: 1000 !important; + [class*='buttonNext'] { + background-color: var(--blue-primary-color) !important; + border-color: var(--blue-primary-color) !important; + color: var(--base-pure-white) !important; + --button-color: var(--blue-primary-color); + --button-color-hover: var(--blue-primary-text); + --button-color-active: var(--blue-primary-text); } } + + diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/metadata/page-layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/metadata/page-layout.tsx deleted file mode 100644 index 6f7c0446..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/metadata/page-layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import React from 'react'; - -export function MetadataPage({ params }: { params: { id: string } }) { - return <>Edit Metadata: {params.id}; -} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/page-layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/page-layout.tsx index 809235fb..9538ef84 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/page-layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/page-layout.tsx @@ -1,7 +1,59 @@ 'use client'; -import React from 'react'; +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { + datasetSummaryQueryDoc, + firstIncompleteDatasetEditStep, +} from './dataset-summary'; export function EditPage({ params }: { params: { id: string } }) { - return <>Edit Dataset: {params.id}; + const router = useRouter(); + const routeParams = useParams<{ + entityType: string; + entitySlug: string; + }>(); + + const datasetQuery = useQuery([`dataset_title_${params.id}`], () => + GraphQL( + datasetSummaryQueryDoc, + { + [routeParams.entityType]: routeParams.entitySlug, + }, + { + filters: { + id: params.id, + }, + } + ) + ); + + const dataset = datasetQuery.data?.datasets[0]; + + useEffect(() => { + if (datasetQuery.isLoading) return; + const step = dataset + ? firstIncompleteDatasetEditStep(dataset) + : 'resources'; + router.replace( + `/dashboard/${routeParams.entityType}/${routeParams.entitySlug}/dataset/${params.id}/edit/${step}` + ); + }, [ + dataset, + datasetQuery.isLoading, + params.id, + routeParams.entitySlug, + routeParams.entityType, + router, + ]); + + return ( +
+ +
+ ); } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/publish/page.tsx index d9a468b4..08428cd2 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/publish/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/publish/page.tsx @@ -1,29 +1,28 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { graphql } from '@/gql'; -import { DatasetsSummaryQuery } from '@/gql/generated/graphql'; +import { + IconArrowRight, + IconInfoCircle, + IconPencil, + IconSend, +} from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, Button, - Dialog, - Icon, + FileCard, + SectionCard, Spinner, - Table, Tag, Text, toast, } from 'opub-ui'; import { GraphQL } from '@/lib/api'; -import { formatDate, getWebsiteTitle, toTitleCase } from '@/lib/utils'; -import { Icons } from '@/components/icons'; +import { formatDate, getWebsiteTitle } from '@/lib/utils'; import { RichTextRenderer } from '@/components/RichTextRenderer'; const datasetSummaryQuery = graphql(` @@ -42,12 +41,14 @@ const datasetSummaryQuery = graphql(` id type name - description - schema { - fieldName - id + created + fileDetails { format - description + size + created + file { + name + } } } tags { @@ -58,12 +59,18 @@ const datasetSummaryQuery = graphql(` id name } + geographies { + id + name + } id title description created modified datasetType + license + accessType promptMetadata } } @@ -80,25 +87,13 @@ const publishDatasetMutation = graphql(` } `); -interface SchemaField { - fieldName: string; - description?: string | null; - format?: string | null; -} - -interface AccessModelResource { - resource: { - name: string; - type?: string | null; - }; -} - -interface ResourceSummary { - name: string; - type: string; - schema?: SchemaField[] | null; - modelResources?: AccessModelResource[]; -} +const LICENSE_LABELS: Record = { + GOVERNMENT_OPEN_DATA_LICENSE: 'Government Open Data License', + CC_BY_4_0_ATTRIBUTION: 'CC BY 4.0', + CC_BY_SA_4_0_ATTRIBUTION_SHARE_ALIKE: 'CC BY-SA 4.0', + OPEN_DATA_COMMONS_BY_ATTRIBUTION: 'Open Data Commons By Attribution', + OPEN_DATABASE_LICENSE: 'Open Database License', +}; interface PromptMetadata { task_type?: string; @@ -110,132 +105,70 @@ interface PromptMetadata { has_example_responses?: boolean; } -type DatasetSummaryResult = DatasetsSummaryQuery['datasets'][number]; +function isPromptMetadata(value: unknown): value is PromptMetadata { + return typeof value === 'object' && value !== null; +} -type AccessModelSummary = { - id?: string; - name: string; - type: string; - modelResources?: AccessModelResource[]; -}; +function formatFileSize(bytes?: number | null): string { + if (bytes == null || Number.isNaN(bytes)) return '—'; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} -function hasAccessModels( - dataset: object -): dataset is { accessModels: AccessModelSummary[] } { - return 'accessModels' in dataset && Array.isArray(dataset.accessModels); +function formatUploadedAt(value?: string | null): string { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } -function isPromptMetadata(value: unknown): value is PromptMetadata { - return typeof value === 'object' && value !== null; +function resourceFormat(item: { + name: string; + type: string; + fileDetails?: { + format?: string | null; + file?: { name?: string | null } | null; + } | null; +}): string { + const fromDetails = item.fileDetails?.format?.replace('.', '').toUpperCase(); + if (fromDetails) return fromDetails; + const fromType = item.type?.split('.').pop()?.toUpperCase(); + if (fromType && fromType !== 'FILE') return fromType; + const original = item.fileDetails?.file?.name || item.name; + const ext = original.split('.').pop(); + return ext ? ext.toUpperCase() : 'FILE'; } -interface DialogTableRow { - dialog: AccessModelResource[] | SchemaField[]; +function originalName(item: { + fileDetails?: { file?: { name?: string | null } | null } | null; +}): string | undefined { + const name = item.fileDetails?.file?.name; + if (!name) return undefined; + return name.replace(/^resources\//, ''); } -const generateColumnData = (name: string) => { - return [ - { - accessorKey: 'name', - header: `Name of the ${name}`, - }, - { - accessorKey: 'type', - header: 'Type', - }, - { - accessorKey: 'dialog', - header: `${name === 'Access Type' ? 'Resources' : 'Fields'}`, - cell: ({ row }: { row: { original: DialogTableRow } }) => { - return ( - <> - - - - - - {name === 'Access Type' ? ( - { - if ('resource' in item) { - return { - name: item.resource.name, - type: item.resource.type, - }; - } - return { name: '', type: '' }; - })} - hideFooter - /> - ) : ( -
{ - if ('fieldName' in item) { - return { - name: item.fieldName, - description: item.description, - format: item.format, - }; - } - return { name: '', description: '', format: '' }; - })} - hideFooter - /> - )} - - - - ); - }, - }, - ]; -}; +function ReviewField({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+ + {label.toUpperCase()} + +
{children}
+
+ ); +} -const generateTableData = (name: string, data: ResourceSummary[]) => { - return data.map((item) => { - const permission = item.type.split('.').pop(); - return { - name: item.name, - type: - name === 'Access Type' - ? toTitleCase((permission ?? item.type).toLowerCase()) - : item.type, - dialog: (name === 'Access Type' ? item.modelResources : item.schema) ?? [], - }; - }); -}; +function dash(value?: string | null): string { + return value?.trim() ? value : '—'; +} const Page = () => { const params = useParams<{ @@ -243,100 +176,31 @@ const Page = () => { entitySlug: string; id: string; }>(); + const router = useRouter(); - const getDatasetsSummary = useQuery([`summary_${params.id}`], () => + const getDatasetsSummary = useQuery( + [`summary_${params.id}`], + () => GraphQL( datasetSummaryQuery, { [params.entityType]: params.entitySlug, }, { filters: { id: params.id } } - ) - ); - - useEffect(() => { - getDatasetsSummary.refetch(); - }); + ), + { refetchOnMount: true } + ); const dataset = getDatasetsSummary.data?.datasets[0]; const isPromptDataset = dataset?.datasetType === 'PROMPT'; const promptMetadata = isPromptMetadata(dataset?.promptMetadata) ? dataset.promptMetadata : null; - const accessModels = - dataset && hasAccessModels(dataset) ? dataset.accessModels : undefined; - const Summary = [ - { - kind: 'resources' as const, - name: isPromptDataset ? 'Prompt Files' : 'Resource', - data: dataset?.resources, - error: - getDatasetsSummary.data && (dataset?.resources.length ?? 0) === 0 - ? isPromptDataset - ? 'No Prompt Files found. Please add to continue.' - : 'No Resources found. Please add to continue.' - : '', - errorType: 'critical', - }, - ...(process.env.NEXT_PUBLIC_ENABLE_ACCESSMODEL === 'true' - ? [ - { - kind: 'access' as const, - name: 'Access Type', - data: accessModels, - error: - getDatasetsSummary.data && (accessModels?.length ?? 0) === 0 - ? 'No Access Type found. Please add to continue.' - : '', - errorType: 'critical', - }, - ] - : []), - { - kind: 'metadata' as const, - name: 'Metadata', - data: dataset?.metadata, - error: - (dataset?.sectors.length ?? 0) === 0 || - (dataset?.tags.length ?? 0) === 0 || - (dataset?.description?.length ?? 0) === 0 - ? 'Tags or Description or Sectors is missing. Please add to continue.' - : '', - errorType: 'critical', - }, - ...(isPromptDataset - ? [ - { - kind: 'prompt' as const, - name: 'Prompt Metadata', - data: promptMetadata, - error: '', - errorType: 'info', - }, - ] - : []), - ]; + const editBase = `/dashboard/${params.entityType}/${params.entitySlug}/dataset/${params.id}/edit`; + const filesStep = `${editBase}/resources`; + const metadataStep = `${editBase}/metadata`; - const PrimaryMetadata = [ - { - label: 'Dataset Name', - value: dataset?.title, - }, - { - label: 'Description', - value: dataset?.description, - }, - { - label: 'Date of Creation', - value: formatDate(dataset?.created ?? null) || '', - }, - { - label: 'Date of Last Update', - value: formatDate(dataset?.modified ?? null) || '', - }, - ]; - const router = useRouter(); const PUBLISH_SUCCESS_TOAST_ID = 'dataset-publish-success'; const PUBLISH_ERROR_TOAST_ID = 'dataset-publish-error'; @@ -372,300 +236,314 @@ const Page = () => { } ); - const isPublishDisabled = (current?: DatasetSummaryResult | null) => { - if (!current) return true; - - const hasResources = current.resources.length > 0; - const hasAccessModelsFlag = - hasAccessModels(current) && (current.accessModels?.length ?? 0) > 0; - const isAccessModelEnabled = - process.env.NEXT_PUBLIC_ENABLE_ACCESSMODEL === 'true'; - const hasRequiredMetadata = - current.sectors.length > 0 && - (current.description?.length ?? 0) > 0 && - current.tags.length > 0; - - // No resources - if (!hasResources) return true; - - // Access model check if enabled - if (isAccessModelEnabled && !hasAccessModelsFlag) return true; - - // Required metadata check - return !hasRequiredMetadata; - }; + const hasResources = (dataset?.resources.length ?? 0) > 0; + const hasRequiredMetadata = + (dataset?.sectors.length ?? 0) > 0 && + (dataset?.description?.length ?? 0) > 0 && + (dataset?.tags.length ?? 0) > 0 && + Boolean(dataset?.license); + const isPublishDisabled = !dataset || !hasResources || !hasRequiredMetadata; const [sourceTitle, setSourceTitle] = useState(null); + const sourceUrl = dataset?.metadata.find( + (item) => item.metadataItem?.dataType === 'URL' + )?.value; useEffect(() => { const fetchTitle = async () => { + if (!sourceUrl) { + setSourceTitle(null); + return; + } try { - const urlItem = dataset?.metadata.find( - (item) => item.metadataItem?.dataType === 'URL' - ); - - if (urlItem && urlItem.value) { - const title = await getWebsiteTitle(urlItem.value); - setSourceTitle(title); - } + const title = await getWebsiteTitle(sourceUrl); + setSourceTitle(title); } catch (error) { console.error('Error fetching website title:', error); } }; - fetchTitle(); - }, [dataset?.metadata, getDatasetsSummary.data?.datasets, getDatasetsSummary.isLoading]); + void fetchTitle(); + }, [sourceUrl]); + + const extraMetadata = + dataset?.metadata.filter((item) => item.metadataItem?.dataType !== 'URL') ?? + []; + const totalBytes = dataset?.resources.reduce( + (sum, item) => sum + (item.fileDetails?.size ?? 0), + 0 + ); + const filesLabel = isPromptDataset ? 'Prompt Files' : 'Uploaded Files'; + + const editAction = (label: string, href: string) => [ + { + icon: IconPencil, + content: label, + onAction: () => router.push(href), + }, + ]; return ( - <> -
-
- - REVIEW DATASET DETAILS - - : - - Please check all the dataset details below before publishing - -
-
- {getDatasetsSummary.isLoading || mutationLoading ? ( -
- -
- ) : ( - <> - {Summary.map((item, index) => ( - - - -
- - {item.name} +
+
+ {getDatasetsSummary.isLoading || mutationLoading ? ( +
+ +
+ ) : ( + <> + +
+ + + {dash(dataset?.title)} + + + + {dataset?.description ? ( + + ) : ( + — + )} + +
+ + {dataset?.sectors?.length ? ( + + {dataset.sectors + .map((sector) => sector.name) + .join(', ')} + + ) : ( + — + )} + + + {dataset?.geographies?.length ? ( + + {dataset.geographies.map((geo) => geo.name).join(', ')} + + ) : ( + — + )} + +
+ + {dataset?.tags?.length ? ( +
+ {dataset.tags.map((tag) => ( + + {tag.value} + + ))} +
+ ) : ( + — + )} +
+
+ + {sourceUrl ? ( + + + {sourceTitle?.trim() || sourceUrl} - {item.error !== '' && ( -
- - - {item.error} - -
- )} + + ) : ( + — + )} +
+ + + {formatDate(dataset?.created ?? null) || '—'} + + +
+ {extraMetadata.map((item) => ( + + {dash(item.value)} + + ))} +
+
+ + +
+ + + {dataset?.accessType && dataset.accessType !== 'PUBLIC' + ? 'Restricted Access' + : 'Open Access'} + + + + + {dataset?.license + ? LICENSE_LABELS[dataset.license] || dataset.license + : '—'} + + +
+
+ + {isPromptDataset ? ( + +
+ + + {dash( + promptMetadata?.task_type + ?.replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()) + )} + + + + {dash(promptMetadata?.domain)} + + + {promptMetadata?.target_languages?.length ? ( +
+ {promptMetadata.target_languages.map((lang) => ( + {lang} + ))}
- - -
- {item.kind === 'prompt' ? ( -
- {item.data?.task_type && ( -
- - Task Type: - - - {item.data.task_type - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => - c.toUpperCase() - )} - -
- )} - {item.data?.domain && ( -
- - Domain: - - - {item.data.domain} - -
- )} - {(item.data?.target_languages?.length ?? 0) > 0 && ( -
- - Target Languages: - -
- {item.data?.target_languages?.map( - (lang: string, idx: number) => ( - {lang} - ) - )} -
-
- )} - {item.data?.prompt_format && ( -
- - Prompt Format: - - - {item.data.prompt_format} - -
- )} - {(item.data?.target_model_types?.length ?? 0) > 0 && ( -
- - Target Model Types: - -
- {item.data?.target_model_types?.map( - (model: string, idx: number) => ( - - {model - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => - c.toUpperCase() - )} - - ) - )} -
-
- )} -
- - Has System Prompt: - - - {item.data?.has_system_prompt ? 'Yes' : 'No'} - -
-
- - Has Example Responses: - - - {item.data?.has_example_responses - ? 'Yes' - : 'No'} - -
-
- ) : item.kind !== 'metadata' ? ( - item.data && - item.data.length > 0 && ( -
- ) - ) : ( -
- {PrimaryMetadata.map( - (item, index) => - item.value && ( -
- - {item.label}: - - - - -
- ) - )} - - {item.data?.map((metadataItem, index) => ( -
- - {toTitleCase(metadataItem.metadataItem.label)}: - - - {metadataItem.metadataItem.dataType !== 'URL' ? ( - - {' '} - {metadataItem.value === '' - ? 'NA' - : metadataItem.value} - - ) : ( - - - {sourceTitle?.trim() - ? sourceTitle - : 'Visit Website'} - - - )} -
- ))} -
- - Sectors: - -
- {dataset?.sectors?.map((sector, index) => ( - {sector.name} - ) - )} -
-
-
- - Tags: - -
- {dataset?.tags.map((tag, index) => ( - {tag.value} - ) - )} -
-
-
- )} + ) : ( + — + )} + + + {promptMetadata?.target_model_types?.length ? ( +
+ {promptMetadata.target_model_types.map((model) => ( + + {model + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase())} + + ))}
- - - - ))} + ) : ( + — + )} +
+ + + ) : null} + + + {hasResources ? ( +
+ {dataset?.resources.map((item) => ( + router.push(`${filesStep}?id=${item.id}`)} + /> + ))} + + Total file size: {formatFileSize(totalBytes)} + +
+ ) : ( + + {isPromptDataset + ? 'Add at least one prompt file to continue.' + : 'Add at least one dataset file to continue.'} + + )} +
+ +
+ +
+ + Public dataset + + + Once published, this dataset will be publicly available on + CivicDataSpace. Anyone can discover and access its published + resources. Before publishing, make sure you have permission to + share all included information and that it does not contain + private or restricted content. + +
+
+ +
+ + Your dataset will be publicly available immediately after + publishing. + - - )} -
+ + +
+ +
+ + )} - + ); }; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx deleted file mode 100644 index 2ba88ffc..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx +++ /dev/null @@ -1,895 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { graphql } from '@/gql'; -import { - CreateFileResourceInput, - PromptFormat, - SchemaUpdateInput, - UpdateFileResourceInput, - UpdatePromptResourceInput, -} from '@/gql/generated/graphql'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import { parseAsString, useQueryState } from 'nuqs'; -import { - Button, - Checkbox, - Combobox, - Divider, - DropZone, - Text, - TextField, - toast, -} from 'opub-ui'; - -import { GraphQL } from '@/lib/api'; -import { enumValues } from '@/lib/enumValues'; -import { Loading } from '@/components/loading'; -import PdfPreview from '../../../../../../../../(user)/components/PdfPreview'; -import { useDatasetEditStatus } from '../../context'; -import { TListItem } from '../page-layout'; -import PreviewData from './PreviewData'; -import { - createResourceFilesDoc, - updateResourceDoc, - updateSchema, -} from './query'; -import ResourceHeader from './ResourceHeader'; -import { ResourceSchema } from './ResourceSchema'; - -interface EditProps { - refetch: () => void; - allResources: TListItem[]; - isPromptDataset?: boolean; -} - -const resourceDetails = graphql(` - query resourceById($resourceId: UUID!) { - resourceById(resourceId: $resourceId) { - id - dataset { - pk - } - previewData { - columns - rows - } - previewDetails { - endEntry - isAllEntries - startEntry - } - previewEnabled - schema { - id - fieldName - format - description - } - type - name - description - created - fileDetails { - id - resource { - pk - } - format - file { - name - path - url - } - size - created - modified - } - promptDetails { - promptFormat - hasSystemPrompt - hasExampleResponses - avgPromptLength - promptCount - } - } - } -`); - -// Mutation to update prompt resource metadata -const updatePromptResourceMutationDoc = graphql(` - mutation UpdatePromptResource($updateInput: UpdatePromptResourceInput!) { - updatePromptResource(updateInput: $updateInput) { - success - errors { - fieldErrors { - field - messages - } - nonFieldErrors - } - data { - id - promptDetails { - promptFormat - hasSystemPrompt - hasExampleResponses - } - } - } - } -`); - -// Prompt format templates for different prompt types (CSV format for multiple prompts) -const PROMPT_FORMAT_TEMPLATES: Record< - string, - { description: string; template: string } -> = { - INSTRUCTION: { - description: - 'Single instruction with expected output format. Each row is one prompt.', - template: `instruction,input,output -"Translate the following English text to Hindi","Hello, how are you?","नमस्ते, आप कैसे हैं?" -"Summarize the following text","Artificial Intelligence is transforming industries worldwide.","AI is changing industries globally." -"Convert this sentence to past tense","I am going to the store","I went to the store"`, - }, - CHAT: { - description: - 'Multi-turn conversation format. Each row contains a complete conversation with system, user, and assistant messages.', - template: `system_prompt,user_message,assistant_response -"You are a helpful assistant.","What is the capital of India?","The capital of India is New Delhi." -"You are a math tutor.","What is 15 + 27?","15 + 27 equals 42." -"You are a language expert.","How do you say hello in Spanish?","In Spanish, hello is 'Hola'."`, - }, - COMPLETION: { - description: - 'Text completion format with prompt and completion pairs. Each row is one prompt-completion pair.', - template: `prompt,completion -"The capital of France is"," Paris, which is known for the Eiffel Tower." -"The largest ocean on Earth is"," the Pacific Ocean, covering more than 60 million square miles." -"The chemical symbol for gold is"," Au, derived from the Latin word aurum."`, - }, - FEW_SHOT: { - description: - 'Examples followed by the actual task. Each row contains example pairs and the task.', - template: `example_input_1,example_output_1,example_input_2,example_output_2,task_input,task_output -"happy","sad","big","small","hot","cold" -"up","down","left","right","forward","backward" -"day","night","sun","moon","light","dark"`, - }, - CHAIN_OF_THOUGHT: { - description: - 'Step-by-step reasoning format. Each row shows question, reasoning steps, and answer.', - template: `question,reasoning,answer -"If John has 5 apples and gives 2 to Mary, how many does he have?","John starts with 5 apples. He gives away 2 apples. 5 - 2 = 3.","John has 3 apples." -"A train travels 60 km in 1 hour. How far does it travel in 3 hours?","Speed is 60 km/hour. Distance = Speed × Time. Distance = 60 × 3 = 180 km.","The train travels 180 km." -"If 3 pencils cost $6, how much does 1 pencil cost?","Total cost is $6 for 3 pencils. Cost per pencil = $6 ÷ 3 = $2.","One pencil costs $2."`, - }, - ZERO_SHOT: { - description: - 'Direct task without examples. Each row is one task-input-output triplet.', - template: `task,input,output -"Classify the sentiment of the following text","I love this product! It works great.","positive" -"Identify the language","Bonjour, comment allez-vous?","French" -"Extract the main topic","The article discusses climate change and its impact on agriculture.","climate change"`, - }, - OTHER: { - description: - 'Custom format - define your own columns. Each row is one prompt.', - template: `custom_field_1,custom_field_2,custom_field_3 -"value1","value2","value3" -"value4","value5","value6" -"value7","value8","value9"`, - }, -}; - -export const EditResource = ({ - refetch, - allResources, - isPromptDataset = false, -}: EditProps) => { - const UPDATE_RESOURCE_ERROR_TOAST_ID = 'dataset-resource-update-error'; - const UPDATE_SCHEMA_ERROR_TOAST_ID = 'dataset-schema-update-error'; - const CREATE_RESOURCE_ERROR_TOAST_ID = 'dataset-resource-create-error'; - const PROMPT_RESOURCE_ERROR_TOAST_ID = 'dataset-prompt-resource-error'; - const getErrorMessage = (err: unknown, fallback: string) => - typeof err === 'object' && - err !== null && - 'message' in err && - typeof err.message === 'string' && - err.message.trim() - ? err.message.trim() - : fallback; - - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - - const [resourceId, setResourceId] = useQueryState('id', parseAsString); - const [schema, setSchema] = React.useState< - { - id?: string; - fieldName?: string | null; - format?: string | null; - description?: string | null; - }[] - >([]); - - const resourceDetailsQuery = useQuery( - // Use a stable key when resourceId is empty/invalid - resourceId && resourceId.trim() - ? [`fetch_resource_details_${resourceId}`] - : ['fetch_resource_details_disabled'], - () => { - if (!resourceId || !resourceId.trim()) { - // Return a rejected promise or throw an error to prevent execution - return Promise.reject(new Error('No resource ID provided')); - } - return GraphQL( - resourceDetails, - { - [params.entityType]: params.entitySlug, - }, - { resourceId: resourceId } - ); - }, - { - enabled: !!(resourceId && resourceId.trim()), - // Prevent retries when there's no resourceId - retry: false, - // Don't refetch when resourceId is empty - refetchOnWindowFocus: !!(resourceId && resourceId.trim()), - } - ); - - const updateResourceMutation = useMutation( - (data: { - fileResourceInput: UpdateFileResourceInput; - isResetSchema: boolean; - }) => - GraphQL( - updateResourceDoc, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: () => { - toast('File changes saved', { - action: { - label: 'Dismiss', - onClick: () => {}, - }, - }); - - resourceDetailsQuery.refetch(); - }, - onError: (err: unknown) => { - toast(getErrorMessage(err, 'Unable to save file changes right now.'), { - id: UPDATE_RESOURCE_ERROR_TOAST_ID, - }); - setFile([]); - }, - } - ); - - const updateSchemaMutation = useMutation( - (data: { schemaUpdateInput: SchemaUpdateInput }) => - GraphQL( - updateSchema, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: () => { - toast('Schema Updated Successfully', { - action: { - label: 'Dismiss', - onClick: () => {}, - }, - }); - }, - onError: (err: unknown) => { - toast(`Error: ${getErrorMessage(err, 'Unable to update schema right now.')}`, { - id: UPDATE_SCHEMA_ERROR_TOAST_ID, - }); - }, - } - ); - - const createResourceMutation = useMutation( - (data: { fileResourceInput: CreateFileResourceInput }) => - GraphQL( - createResourceFilesDoc, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: (data) => { - const createdId = data.createFileResources[0]?.id; - if (typeof createdId === 'string') { - setResourceId(createdId); - } - toast('Resource Added Successfully', { - action: { - label: 'Dismiss', - onClick: () => {}, - }, - }); - // - resourceDetailsQuery.refetch(); - }, - onError: (err: unknown) => { - toast(getErrorMessage(err, 'Unable to add resource right now.'), { - id: CREATE_RESOURCE_ERROR_TOAST_ID, - action: { - label: 'Dismiss', - onClick: () => {}, - }, - }); - setFile([]); - }, - } - ); - const [isSheetOpen, setIsSheetOpen] = useState(false); - - const [resourceName, setResourceName] = React.useState( - resourceDetailsQuery.data?.resourceById.name ?? '' - ); - - const [showPreview, setShowPreview] = useState(false); - const [previewEnable, setPreviewEnable] = useState(false); - - const [previewDetails, setPreviewDetails] = useState({ - startEntry: 0, - endEntry: 0, - isAllEntries: false, - }); - const [previewData, setPreviewData] = useState<{ - rows: unknown[][]; - columns: string[]; - }>({ - rows: [], - columns: [], - }); - - // Prompt-specific state - const [promptFormat, setPromptFormat] = useState( - undefined - ); - const [hasSystemPrompt, setHasSystemPrompt] = useState(false); - const [hasExampleResponses, setHasExampleResponses] = useState(false); - const [prevResourceById, setPrevResourceById] = useState( - resourceDetailsQuery.data?.resourceById - ); - if (resourceDetailsQuery.data?.resourceById !== prevResourceById) { - const ResourceData = resourceDetailsQuery.data?.resourceById; - setPrevResourceById(ResourceData); - setResourceName(ResourceData?.name ?? ''); - setPreviewEnable(ResourceData?.previewEnabled ?? false); - setPreviewDetails({ - startEntry: ResourceData?.previewDetails?.startEntry ?? 0, - endEntry: ResourceData?.previewDetails?.endEntry ?? 0, - isAllEntries: ResourceData?.previewDetails?.isAllEntries ?? false, - }); - setPreviewData({ - rows: (ResourceData?.previewData?.rows ?? []).map((row: unknown) => - Array.isArray(row) ? row.map((cell: unknown) => cell) : [] - ), - columns: ResourceData?.previewData?.columns ?? [], - }); - if (isPromptDataset && ResourceData?.promptDetails) { - setPromptFormat(ResourceData.promptDetails.promptFormat || undefined); - setHasSystemPrompt(ResourceData.promptDetails.hasSystemPrompt || false); - setHasExampleResponses( - ResourceData.promptDetails.hasExampleResponses || false - ); - } - const schemaData = ResourceData?.schema; - if (schemaData && Array.isArray(schemaData)) { - setSchema(schemaData); - } - } - - // Mutation for updating prompt resource metadata - const updatePromptResourceMutation = useMutation( - (data: { updateInput: UpdatePromptResourceInput }) => - GraphQL( - updatePromptResourceMutationDoc, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: (res) => { - if (res.updatePromptResource?.success) { - toast('Prompt file metadata updated!'); - resourceDetailsQuery.refetch(); - } else { - toast( - 'Error: ' + - (res.updatePromptResource?.errors?.nonFieldErrors?.[0] || - 'Unknown error') - ); - } - }, - onError: (err: unknown) => { - toast( - `Error: ${getErrorMessage(err, 'Unable to update prompt metadata right now.')}`, - { id: PROMPT_RESOURCE_ERROR_TOAST_ID } - ); - }, - } - ); - - // Function to save prompt resource metadata - const savePromptResourceMetadata = (updates: { - promptFormat?: string; - hasSystemPrompt?: boolean; - hasExampleResponses?: boolean; - }) => { - const newPromptFormat = - updates.promptFormat !== undefined ? updates.promptFormat : promptFormat; - const newHasSystemPrompt = - updates.hasSystemPrompt !== undefined - ? updates.hasSystemPrompt - : hasSystemPrompt; - const newHasExampleResponses = - updates.hasExampleResponses !== undefined - ? updates.hasExampleResponses - : hasExampleResponses; - - if (updates.promptFormat !== undefined) - setPromptFormat(updates.promptFormat); - if (updates.hasSystemPrompt !== undefined) - setHasSystemPrompt(updates.hasSystemPrompt); - if (updates.hasExampleResponses !== undefined) - setHasExampleResponses(updates.hasExampleResponses); - - if (!resourceId) return; - - updatePromptResourceMutation.mutate({ - updateInput: { - resource: resourceId as `${string}-${string}-${string}-${string}-${string}`, - promptFormat: newPromptFormat ?? null, - hasSystemPrompt: newHasSystemPrompt, - hasExampleResponses: newHasExampleResponses, - }, - }); - }; - - useEffect(() => { - resourceDetailsQuery.refetch(); - }, [resourceDetailsQuery]); - - const handleResourceChange = (nextResourceId: string) => { - setResourceId(nextResourceId, { shallow: false }); - refetch(); - }; - - const [file, setFile] = React.useState([]); - - const dropZone = React.useCallback( - (_dropFiles: File[], acceptedFiles: File[]) => { - createResourceMutation.mutate({ - fileResourceInput: { - dataset: params.id, - files: acceptedFiles, - }, - }); - setFile((files) => [...files, ...acceptedFiles]); - setIsSheetOpen(false); - }, - [createResourceMutation, params.id] - ); - - const uploadedFile = file.length > 0 && ( -
- {file.map((file, index) => { - return
{file.name}
; - })} -
- ); - - const onDrop = React.useCallback( - (_dropFiles: File[], acceptedFiles: File[]) => { - updateResourceMutation.mutate( - { - fileResourceInput: { - id: resourceId, - file: acceptedFiles[0], - }, - isResetSchema: true, - }, - { - onSuccess: () => { - // Automatically trigger schema mutation after file upload - - resourceDetailsQuery.refetch(); - }, - } - ); - }, - [resourceId, updateResourceMutation, resourceDetailsQuery] - ); - - const fileInput = ( -
- - {( - resourceDetailsQuery.data?.resourceById.fileDetails?.file.name ?? '' - ).replace('resources/', '')}{' '} - -
- ); - - const listViewFunction = () => { - setResourceId(''); - }; - - const saveResource = () => { - updateResourceMutation.mutate({ - fileResourceInput: { - id: resourceId, - name: resourceName || '', - previewEnabled: previewEnable, - previewDetails: { - startEntry: 1, - endEntry: 5, - isAllEntries: previewDetails.isAllEntries, - }, - }, - isResetSchema: false, - }); - }; - - const { setStatus } = useDatasetEditStatus(); - - useEffect(() => { - setStatus( - updateResourceMutation.isLoading || updateSchemaMutation.isLoading - ? 'loading' - : 'success' - ); // update based on mutation state - }, [ - setStatus, - updateResourceMutation.isLoading, - updateSchemaMutation.isLoading, - ]); - - const resourceFormat = - resourceDetailsQuery.data?.resourceById.fileDetails?.format?.toLowerCase(); - const pdfUrl = `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/download/resource/${resourceId}`; - return ( -
- {resourceDetailsQuery.data?.resourceById ? ( -
- - - - -
-
-
- setResourceName(text)} - helpText={`Character limit: ${resourceName?.length}/200`} - onBlur={saveResource} - multiline={2} - label={ - isPromptDataset ? 'Prompt File Name' : 'Data File Name' - } - name="a" - required - requiredIndicator={true} - /> -
- {isPromptDataset ? ( -
- ({ - label: name - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => c.toUpperCase()), - value: name, - })) - } - selectedValue={promptFormat ? promptFormat : ''} - onChange={(value) => { - // Handle both array and string values - let selectedValue: string | undefined; - if (Array.isArray(value)) { - selectedValue = - value.length > 0 ? value[0]?.value : undefined; - } else { - selectedValue = value || undefined; - } - // Toggle: if same value selected, clear it - if (selectedValue === promptFormat) { - selectedValue = undefined; - } - setPromptFormat(selectedValue); - savePromptResourceMetadata({ - promptFormat: selectedValue, - }); - }} - /> -
- { - savePromptResourceMetadata({ - hasSystemPrompt: Boolean(checked), - }); - }} - > - Has System Prompt - - { - savePromptResourceMetadata({ - hasExampleResponses: Boolean(checked), - }); - }} - > - Has Example Responses - -
- {promptFormat && PROMPT_FORMAT_TEMPLATES[promptFormat] && ( -
- - Expected Format Template - - - {PROMPT_FORMAT_TEMPLATES[promptFormat].description} - -
-
- - - {PROMPT_FORMAT_TEMPLATES[promptFormat].template - .split('\n')[0] - .split(',') - .map((header, idx) => ( - - ))} - - - - {PROMPT_FORMAT_TEMPLATES[promptFormat].template - .split('\n') - .slice(1) - .map((row, rowIdx) => { - const cells = - row.match(/(".*?"|[^,]+)(?=\s*,|\s*$)/g) || - []; - return ( - - {cells.map((cell, cellIdx) => ( - - ))} - - ); - })} - -
- {header.trim()} -
- {cell.replace(/^"|"$/g, '').trim()} -
-
-
- )} -
- ) : ( -
- - Good practices for naming Data Files - -
-
    -
  1. - Try to include as many keywords as possible in the name -
  2. -
  3. Mention the date or time period of the Data File
  4. -
  5. Mention the geography if applicable
  6. -
  7. - Follow a similar format for naming all Data Files in a - Dataset -
  8. -
-
-
- )} -
-
- - {isPromptDataset - ? 'File associated with Prompt File' - : 'File associated with Data File'} - -
- {fileInput} -
- - - -
-
-
-
- - {resourceFormat !== 'zip' && ( -
- { - const newValue = !previewEnable; - setPreviewEnable(newValue); - updateResourceMutation.mutate({ - fileResourceInput: { - id: resourceId, - name: resourceName || '', - previewEnabled: newValue, // use new value here - previewDetails: { - startEntry: 1, - endEntry: 5, - isAllEntries: previewDetails.isAllEntries, - }, - }, - isResetSchema: false, - }); - }} - > - Preview Enabled - -
- -
- {/* {previewEnable && ( - <> - - handlePreviewDetailsChange( - 'isAllEntries', - !previewDetails.isAllEntries - ) - } - > - Show all entries - - {!previewDetails.isAllEntries && ( - <> - - handlePreviewDetailsChange('startEntry', value) - } - type="number" - /> - - handlePreviewDetailsChange('endEntry', value) - } - type="number" - /> - - )} - - )} */} -
- )} - {showPreview && - previewEnable && - (resourceFormat === 'pdf' ? ( - - ) : ( - previewData && - ))} - - {resourceFormat !== 'pdf' && resourceFormat !== 'zip' && ( -
- {/*
- Fields in the Resource - -
*/} - - The Field settings apply to the Resource on a master level and - can not be changed in Access Models. - - - -
- )} -
- ) : ( - - )} -
- ); -}; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PreviewData.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PreviewData.tsx index 683740c6..576e4aa8 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PreviewData.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PreviewData.tsx @@ -1,8 +1,6 @@ -import { DataTable } from 'opub-ui'; +import { Text } from 'opub-ui'; -interface PreviewCell { - getValue: () => unknown; -} +import { TABLE_PREVIEW_HEIGHT } from '@/components/file-preview'; interface EditProps { previewData: { @@ -12,31 +10,69 @@ interface EditProps { } const PreviewData = ({ previewData }: EditProps) => { - const previewColumns = - previewData?.columns?.map((column: string) => ({ - accessorKey: column, - header: column, - cell: ({ cell }: { cell: PreviewCell }) => { - const value = cell.getValue(); - return {value !== null ? String(value) : 'N/A'}; - }, - })) || []; + const columns = previewData.columns ?? []; + const rows = previewData.rows ?? []; - const previewRows = - previewData?.rows?.map((row: unknown[]) => { - const rowData: Record = {}; - previewData.columns.forEach((column: string, index: number) => { - rowData[column] = row[index]; - }); - return rowData; - }) || []; return ( - +
+
+ + Tabular data — first {rows.length} rows + + + Showing {columns.length} columns + +
+
+ + + + + {columns.map((column, index) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {columns.map((column, columnIndex) => { + const value = row[columnIndex]; + return ( + + ); + })} + + ))} + +
+ Read-only preview of the uploaded file +
+ {column} +
+ + {value != null && value !== '' ? String(value) : '—'} + +
+
+ + Read-only preview. Representative rows shown to verify structure and + content. + +
); }; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PublicPlatformImport.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PublicPlatformImport.tsx new file mode 100644 index 00000000..87f62c19 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PublicPlatformImport.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + ImportPlatform, + type ImportPlatformDatasetInput, + type TypeDatasetMutationResponse, +} from '@/gql/generated/graphql'; +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { useMutation } from '@tanstack/react-query'; +import gql from 'graphql-tag'; +import { SparklesIcon } from 'lucide-react'; +import { Button, Form, SectionCard, Select, TextField, toast } from 'opub-ui'; +import { useFormContext } from 'react-hook-form'; + +import { GraphQL } from '@/lib/api'; +import { useDatasetEditStatus } from '../../context'; + +const importPlatformDatasetDoc: TypedDocumentNode< + { importPlatformDataset: TypeDatasetMutationResponse }, + { importInput: ImportPlatformDatasetInput } +> = gql` + mutation ImportPlatformDataset($importInput: ImportPlatformDatasetInput!) { + importPlatformDataset(importInput: $importInput) { + success + errors { + fieldErrors { + messages + } + nonFieldErrors + } + data { + id + title + } + } + } +`; + +const PLATFORM_API: Record = { + github: ImportPlatform.Github, + huggingface: ImportPlatform.Huggingface, + kaggle: ImportPlatform.Kaggle, +}; + +const PLATFORM_OPTIONS = [ + { + label: 'GitHub', + value: 'github', + placeholder: 'https://github.com/owner/repository', + helpText: 'Paste the GitHub repository URL that holds the dataset files.', + }, + { + label: 'Hugging Face', + value: 'huggingface', + placeholder: 'https://huggingface.co/datasets/owner/repository', + helpText: + 'Paste the Hugging Face dataset URL that holds the dataset files.', + }, + { + label: 'Kaggle', + value: 'kaggle', + placeholder: 'https://kaggle.com/datasets/owner/repository', + helpText: 'Paste the Kaggle dataset URL that holds the dataset files.', + }, +]; + +function SyncPlatformError({ error }: { error?: string }) { + const { setError, clearErrors } = useFormContext(); + + useEffect(() => { + if (error) { + setError('platform', { type: 'manual', message: error }); + } else { + clearErrors('platform'); + } + }, [error, setError, clearErrors]); + + return null; +} + +function SyncPlatformValue({ platform }: { platform: string }) { + const { setValue, watch } = useFormContext(); + const formPlatform = watch('platform'); + + useEffect(() => { + if (formPlatform === platform) return; + setValue('platform', platform, { shouldValidate: false }); + }, [formPlatform, platform, setValue]); + + return null; +} + +export interface PlatformImportDraft { + platform: string; + datasetUrl: string; +} + +const FORM_PLATFORM: Record = { + GITHUB: 'github', + HUGGINGFACE: 'huggingface', + KAGGLE: 'kaggle', +}; + +export function platformDraftFromSource( + source?: { + platform?: string | null; + sourceUrl?: string | null; + } | null +): PlatformImportDraft | null { + const platform = FORM_PLATFORM[source?.platform ?? ''] ?? ''; + const datasetUrl = source?.sourceUrl?.trim() ?? ''; + if (!platform && !datasetUrl) return null; + return { platform, datasetUrl }; +} + +interface PublicPlatformImportProps { + initialDraft?: PlatformImportDraft; + onDraftChange?: (draft: PlatformImportDraft) => void; +} + +function importErrorMessage(err: unknown) { + if ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof err.message === 'string' && + err.message.trim() + ) { + return err.message.trim(); + } + return 'Unable to generate dataset metadata.'; +} + +export function PublicPlatformImport({ + initialDraft, + onDraftChange, +}: PublicPlatformImportProps) { + const { stepShowErrors } = useDatasetEditStatus(); + const router = useRouter(); + const params = useParams<{ + entityType: string; + entitySlug: string; + }>(); + const [platform, setPlatform] = useState(initialDraft?.platform ?? ''); + const [datasetUrl, setDatasetUrl] = useState(initialDraft?.datasetUrl ?? ''); + const [urlBlurred, setUrlBlurred] = useState(false); + const platformFormOptions = useMemo( + () => ({ + defaultValues: { + platform: initialDraft?.platform ?? '', + }, + }), + [initialDraft?.platform] + ); + + const generateMutation = useMutation( + (importInput: ImportPlatformDatasetInput) => + GraphQL( + importPlatformDatasetDoc, + { [params.entityType]: params.entitySlug }, + { importInput } + ), + { + onSuccess: (data) => { + const result = data.importPlatformDataset; + const datasetId = result.data?.id; + if (!result.success || !datasetId) { + toast( + result.errors?.nonFieldErrors?.[0] ?? + result.errors?.fieldErrors?.[0]?.messages[0] ?? + 'Unable to generate dataset metadata.', + { id: 'dataset-platform-generate-error' } + ); + return; + } + + toast('Dataset metadata generated from the platform.'); + router.push( + `/dashboard/${params.entityType}/${params.entitySlug}/dataset/${datasetId}/edit/resources` + ); + }, + onError: (err: unknown) => { + toast(importErrorMessage(err), { + id: 'dataset-platform-generate-error', + }); + }, + } + ); + + useEffect(() => { + onDraftChange?.({ platform, datasetUrl }); + }, [platform, datasetUrl, onDraftChange]); + + const showUrlField = platform.length > 0; + const showUrlError = + showUrlField && + datasetUrl.trim().length === 0 && + (stepShowErrors || urlBlurred); + const platformError = + stepShowErrors && !platform ? 'Select a platform.' : undefined; + const apiPlatform = PLATFORM_API[platform]; + const canGenerate = Boolean(apiPlatform) && datasetUrl.trim().length > 0; + + const generate = () => { + if ( + !apiPlatform || + datasetUrl.trim().length === 0 || + generateMutation.isLoading + ) { + return; + } + generateMutation.mutate({ + platform: apiPlatform, + identifier: datasetUrl.trim(), + }); + }; + + return ( + +
+ + +
+ { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file) replaceMutation.mutate(file); + }} + /> +
+
+ + Prompt File Name{' '} + * + + + {title.length}/{PROMPT_FILE_NAME_MAX_LENGTH} + +
+ + setTitle(value.slice(0, PROMPT_FILE_NAME_MAX_LENGTH)) + } + onBlur={commitName} + /> +
+ +
+ + File associated with this prompt file + +
+ + + {associatedName} + + + {format} + +
+ + {resource?.fileDetails?.file + ? 'File available' + : 'File unavailable'} + + {readOnly ? null : ( +
+ +
+ )} +
+ + + saveField('description', description.trim()) + } + multiline={3} + /> + + - - Create View - - } - /> - - - -
- -
- {addedItems && addedItems.length > 0 ? ( -
- {addedItems.map((item) => ( - - ))} -
- ) : ( - - )} -
-
- ); -} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/review/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/review/page.tsx deleted file mode 100644 index c99a2e86..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/review/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { InProgress } from '@/components/in-progress'; - -export default async function Page({ - params, -}: { - params: Promise<{ id: string }>; -}) { - await params; - return ( - <> - - - ); -} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/page.tsx index e64d505a..95fd4cc8 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/page.tsx @@ -10,7 +10,6 @@ import { testDataset } from '@/config/dashboard'; // import { Icons } from '@/components/icons'; // import { InProgress } from '@/components/in-progress'; -// import { ActionBar } from '../components/action-bar'; export default function Page() { const params = useParams<{ id: string }>(); diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.module.scss new file mode 100644 index 00000000..b4904f33 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.module.scss @@ -0,0 +1,6 @@ +.listing { + --action-primary-basic-default: var(--blue-primary-color); + --action-primary-basic-hovered: var(--blue-primary-text); + --action-primary-basic-pressed: var(--blue-primary-text); + --action-primary-basic-depressed: var(--blue-primary-text); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.ts new file mode 100644 index 00000000..dd117eb4 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.ts @@ -0,0 +1,205 @@ +import { graphql } from '@/gql'; +import { DatasetStatus, DatasetType } from '@/gql/generated/graphql'; +import type { FilterField, SortField } from 'opub-ui'; + +export const DATASET_TABLE_PAGE_SIZE = 10; + +export const DATASET_TABLE_STATUSES = [ + { key: DatasetStatus.Draft, label: 'Draft' }, + { key: DatasetStatus.Published, label: 'Published' }, +]; + +export const INITIAL_DATASET_SORT: SortField[] = [ + { field: 'modified', direction: 'desc' }, +]; + +export const datasetsTableQueryDoc = graphql(` + query DatasetsTableListing( + $filters: [FilterSpec!] + $sortOptions: [SortSpec!] + $limit: Int + $offset: Int + ) { + datasetsTable( + filters: $filters + sortOptions: $sortOptions + limit: $limit + offset: $offset + ) { + totalItemsCount + statusCounts { + status + count + } + data { + id + title + datasetType + status + modified + resourceCount + sectors { + name + } + geographies { + name + } + } + } + } +`); + +export const datasetTableSectorsQueryDoc = graphql(` + query DatasetTableSectorOptions { + sectors { + id + name + } + } +`); + +export const datasetTableGeographiesQueryDoc = graphql(` + query DatasetTableGeographyOptions { + geographies { + id + name + } + } +`); + +export const datasetTableTagsQueryDoc = graphql(` + query DatasetTableTagOptions { + tags { + id + value + } + } +`); + +const API_FILTER_FIELDS: Record = { + datasetType: 'dataset_type', + geography: 'geographies__id', + sector: 'sectors__id', + Tags: 'tags__value', +}; + +export const DATASET_TYPE_FILTER_OPTIONS = [ + { label: 'Dataset', value: DatasetType.Data }, + { label: 'Prompt dataset', value: DatasetType.Prompt }, +]; + +export type DatasetListingRow = { + id: string; + title: string; + datasetType: string; + sector: string; + geography: string; + files: string; + status: DatasetStatus; + modified: string; +}; + +type NamedValue = { name?: string | null }; + +type DatasetTableRecord = { + id: string; + title: string; + datasetType: DatasetType; + status: DatasetStatus; + modified: string; + resourceCount: number; + sectors: NamedValue[]; + geographies: NamedValue[]; +}; + +export function statusFilter(status: string): FilterField { + if (status === DatasetStatus.Draft || status === DatasetStatus.Published) { + return { field: 'status', condition: 'exact', value: status }; + } + + return { + field: 'status', + condition: 'in', + value: `${DatasetStatus.Draft},${DatasetStatus.Published}`, + }; +} + +export function withStatusFilter( + filters: FilterField[], + status: string +): FilterField[] { + return [ + ...filters.filter((filter) => filter.field !== 'status'), + statusFilter(status), + ]; +} + +export function toApiFilters(filters: FilterField[]) { + return filters + .filter((filter) => filter.value.trim()) + .map((filter) => ({ + field: API_FILTER_FIELDS[filter.field] ?? filter.field, + condition: filter.condition, + value: filter.value, + })); +} + +export function toStatusCounts( + counts: Array<{ status: string; count: number }> | undefined +): Record { + const draft = + counts?.find((item) => item.status === DatasetStatus.Draft)?.count ?? 0; + const published = + counts?.find((item) => item.status === DatasetStatus.Published)?.count ?? 0; + + return { + all: draft + published, + [DatasetStatus.Draft]: draft, + [DatasetStatus.Published]: published, + }; +} + +export function hasListingFilters(filters: FilterField[], status: string) { + if (status !== 'all') return true; + return filters.some( + (filter) => filter.field !== 'status' && filter.value.trim() + ); +} + +function joinNames(values: NamedValue[] | null | undefined) { + const names = (values ?? []) + .map((item) => item.name?.trim()) + .filter((name): name is string => Boolean(name)); + return names.length > 0 ? names.join(', ') : '—'; +} + +export function formatUpdated(value?: string | null) { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +function formatFileCount(count: number) { + return count === 1 ? '1 file' : `${count} files`; +} + +export function toListingRows( + datasets: DatasetTableRecord[] +): DatasetListingRow[] { + return datasets.map((dataset) => ({ + id: dataset.id, + title: dataset.title, + datasetType: + dataset.datasetType === DatasetType.Prompt ? 'Prompt dataset' : 'Dataset', + sector: joinNames(dataset.sectors), + geography: joinNames(dataset.geographies), + files: formatFileCount(dataset.resourceCount ?? 0), + status: dataset.status, + modified: formatUpdated(dataset.modified), + })); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page-layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page-layout.tsx deleted file mode 100644 index eeb11a4a..00000000 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page-layout.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client'; - -import { useParams, useRouter } from 'next/navigation'; -import { graphql } from '@/gql'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'opub-ui'; - -import { GraphQL } from '@/lib/api'; -import { ActionBar } from './components/action-bar'; -import { Content } from './components/content'; - -const createDatasetMutationDoc = graphql(` - mutation GenerateDatasetname { - addDataset { - success - errors { - fieldErrors { - messages - } - } - data { - id - title - created - } - } - } -`); - -export const Page = () => { - const params = useParams<{ entityType?: string; entitySlug?: string }>(); - const entityType = params?.entityType; - const entitySlug = params?.entitySlug; - - const isValidParams = - typeof entityType === 'string' && typeof entitySlug === 'string'; - - const ownerArgs: Record | null = isValidParams - ? { [entityType]: entitySlug } - : null; - - const router = useRouter(); - const queryClient = useQueryClient(); - - const { mutate, isLoading } = useMutation( - () => GraphQL(createDatasetMutationDoc, ownerArgs || {}), - { - onSuccess: (data) => { - if (data.addDataset.success) { - toast('Dataset created successfully!'); - if (isValidParams && entityType) { - queryClient.invalidateQueries({ - queryKey: [`create_dataset_${entityType}`], - }); - - router.push( - `/dashboard/${entityType}/${entitySlug}/dataset/${data?.addDataset?.data?.id}/edit/metadata` - ); - } - } else { - const errorMessage = - data.addDataset.errors?.fieldErrors?.[0]?.messages[0] ?? - 'Unable to create dataset'; - toast('Error: ' + errorMessage); - } - }, - } - ); - - if (!isValidParams) { - return null; - } - - // React.useEffect(() => { - // router.prefetch('/dashboard/dataset/new'); - // }, []); - - return ( - <> - { - mutate(); - }, - }} - isLoading={isLoading} - /> - - mutate()} /> - - ); -}; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx index 21c90f70..c6ec1498 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx @@ -1,39 +1,53 @@ 'use client'; +import { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; import { graphql } from '@/gql'; import { DatasetStatus, DatasetType as GqlDatasetType, - Ordering, } from '@/gql/generated/graphql'; +import { useTourTrigger } from '@/hooks/use-tour-trigger'; +import { + IconEyeOff, + IconFile, + IconFileText, + IconPencil, + IconTrash, +} from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { useParams, useRouter } from 'next/navigation'; -import { parseAsString, useQueryState } from 'nuqs'; -import { Button, DataTable, IconButton, Text, toast } from 'opub-ui'; -import { useEffect, useState } from 'react'; +import { + Badge, + DataTable, + Icon, + Text, + toast, + type ColumnFilterConfig, + type DataTableRowAction, + type FilterField, + type SortField, + type TablePaginationState, +} from 'opub-ui'; -import { Icons } from '@/components/icons'; -import { LinkButton } from '@/components/Link'; -import { Loading } from '@/components/loading'; -import { useTourTrigger } from '@/hooks/use-tour-trigger'; import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import { ActionBar } from './components/action-bar'; -import { Content } from './components/content'; import { DatasetType, DatasetTypeModal } from './components/dataset-type-modal'; -import { Navigation } from './components/navigate-org-datasets'; - -const allDatasetsQueryDoc = graphql(` - query allDatasetsQuery($filters: DatasetFilter, $order: DatasetOrder) { - datasets(filters: $filters, order: $order) { - title - id - created - modified - datasetType - } - } -`); +import { + DATASET_TABLE_PAGE_SIZE, + DATASET_TABLE_STATUSES, + DATASET_TYPE_FILTER_OPTIONS, + DatasetListingRow, + datasetsTableQueryDoc, + datasetTableGeographiesQueryDoc, + datasetTableSectorsQueryDoc, + datasetTableTagsQueryDoc, + hasListingFilters, + INITIAL_DATASET_SORT, + toApiFilters, + toListingRows, + toStatusCounts, + withStatusFilter, +} from './listing'; +import styles from './listing.module.scss'; const createDatasetMutationDoc = graphql(` mutation GenerateDatasetName($createInput: CreateDatasetInput) { @@ -60,19 +74,110 @@ const deleteDatasetMutationDoc = graphql(` } `); -const unPublishDataset = graphql(` +const unpublishDatasetMutationDoc = graphql(` mutation unPublishDatasetMutation($datasetId: UUID!) { unPublishDataset(datasetId: $datasetId) { __typename ... on TypeDataset { id title - created } } } `); +const columnContentTypes = [ + 'text', + 'text', + 'text', + 'text', + 'text', + 'text', + 'text', +] as const; + +interface DatasetTableCell { + row: { original: DatasetListingRow }; +} + +const datasetColumns = [ + { + accessorKey: 'title', + header: 'Dataset', + searchable: true, + meta: { truncate: true }, + cell: ({ row }: DatasetTableCell) => ( + + + + + + {row.original.title} + + + ), + }, + { + accessorKey: 'datasetType', + header: 'Dataset Type', + meta: { optional: true }, + }, + { + accessorKey: 'sector', + header: 'Sector', + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'geography', + header: 'Geography', + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'files', + header: 'Files', + meta: { optional: true }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { optional: true }, + cell: ({ row }: DatasetTableCell) => ( + + {row.original.status === DatasetStatus.Published + ? 'Published' + : 'Draft'} + + ), + }, + { + accessorKey: 'modified', + header: 'Last Updated', + }, +]; + +function optionList( + items: Array<{ label: string; value: string }> +): Array<{ label: string; value: string }> { + return [...items].sort((left, right) => + left.label.localeCompare(right.label) + ); +} + +function errorMessage(err: unknown) { + return typeof err === 'object' && + err !== null && + 'message' in err && + typeof err.message === 'string' + ? err.message.split(':')[0] + : 'Unknown error'; +} + export default function DatasetPage() { useTourTrigger(true, 1500); const router = useRouter(); @@ -87,270 +192,265 @@ export default function DatasetPage() { ? { [entityType]: entitySlug } : null; - const [navigationTab, setNavigationTab] = useQueryState( - 'tab', - parseAsString.withDefault('drafts') - ); const [isTypeModalOpen, setIsTypeModalOpen] = useState(false); + const [filters, setFilters] = useState(() => + withStatusFilter([], 'all') + ); + const [sorting, setSorting] = useState(INITIAL_DATASET_SORT); + const [pagination, setPagination] = useState({ + limit: DATASET_TABLE_PAGE_SIZE, + offset: 0, + }); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState('all'); - const AllDatasetsQuery = useQuery( - [ - `fetch_datasets_org_dashboard`, - entityType, - entitySlug, - navigationTab ?? 'drafts', - ], - () => - GraphQL(allDatasetsQueryDoc, ownerArgs || {}, { - filters: { - status: - navigationTab === 'published' - ? DatasetStatus.Published - : DatasetStatus.Draft, - }, - order: { modified: Ordering.Desc }, - }), - { enabled: isValidParams } - ); - - useEffect(() => { - if (isValidParams) { - AllDatasetsQuery.refetch(); + const datasetsQuery = useQuery( + ['datasets_table', entityType, entitySlug, filters, sorting, pagination], + () => + GraphQL(datasetsTableQueryDoc, ownerArgs || {}, { + filters: toApiFilters(filters), + sortOptions: sorting, + limit: pagination.limit, + offset: pagination.offset, + }), + { + enabled: isValidParams, + staleTime: 0, + refetchOnMount: 'always', } - }, [navigationTab, isValidParams, AllDatasetsQuery]); + ); + + const sectorsQuery = useQuery( + ['dataset_table_sectors', entityType, entitySlug], + () => GraphQL(datasetTableSectorsQueryDoc, ownerArgs || {}), + { enabled: isValidParams } + ); + const geographiesQuery = useQuery( + ['dataset_table_geographies', entityType, entitySlug], + () => GraphQL(datasetTableGeographiesQueryDoc, ownerArgs || {}), + { enabled: isValidParams } + ); + const tagsQuery = useQuery( + ['dataset_table_tags', entityType, entitySlug], + () => GraphQL(datasetTableTagsQueryDoc, ownerArgs || {}), + { enabled: isValidParams } + ); - const DeleteDatasetMutation = useMutation( - [`delete_dataset`], + const deleteDatasetMutation = useMutation( + ['delete_dataset'], (data: { datasetId: string }) => GraphQL(deleteDatasetMutationDoc, ownerArgs || {}, { datasetId: data.datasetId, }), { onSuccess: () => { - toast(`Deleted dataset successfully`); - if (isValidParams) { - AllDatasetsQuery.refetch(); - } + toast('Deleted dataset successfully'); + datasetsQuery.refetch(); }, onError: (err: unknown) => { - const message = - typeof err === 'object' && - err !== null && - 'message' in err && - typeof err.message === 'string' - ? err.message.split(':')[0] - : 'Unknown error'; - toast('Error: ' + message); + toast('Error: ' + errorMessage(err)); }, } ); - const CreateDatasetMutation = useMutation( - (datasetType: GqlDatasetType) => - GraphQL(createDatasetMutationDoc, ownerArgs || {}, { - createInput: { datasetType }, - }), - { - onSuccess: (data) => { - setIsTypeModalOpen(false); - if (data.addDataset.success) { - toast('Dataset created successfully!'); - if (isValidParams && entityType && entitySlug) { - const datasetId = data?.addDataset?.data?.id; - // Route to edit page - prompt datasets will show additional fields there - router.push( - `/dashboard/${entityType}/${entitySlug}/dataset/${datasetId}/edit/metadata` - ); - } - } else { - const errorMessage = - data.addDataset.errors?.fieldErrors?.[0]?.messages[0] ?? - 'Unable to create dataset'; - toast('Error: ' + errorMessage); - } - }, - } - ); - const UnpublishDatasetMutation = useMutation( - [`unpublish_dataset`], + + const unpublishDatasetMutation = useMutation( + ['unpublish_dataset'], (data: { datasetId: string }) => - GraphQL(unPublishDataset, ownerArgs || {}, { datasetId: data.datasetId }), + GraphQL(unpublishDatasetMutationDoc, ownerArgs || {}, { + datasetId: data.datasetId, + }), { onSuccess: () => { - toast(`Unpublished dataset successfully`); - if (isValidParams) { - AllDatasetsQuery.refetch(); - } + toast('Unpublished dataset successfully'); + datasetsQuery.refetch(); }, onError: (err: unknown) => { - const message = - typeof err === 'object' && - err !== null && - 'message' in err && - typeof err.message === 'string' - ? err.message.split(':')[0] - : 'Unknown error'; - toast('Error: ' + message); + toast('Error: ' + errorMessage(err)); }, } ); - if (!isValidParams) { - return null; - } - - const navigationOptions = [ - { - label: 'Drafts', - url: `drafts`, - selected: navigationTab === 'drafts', - }, + const createDatasetMutation = useMutation( + (datasetType: GqlDatasetType) => + GraphQL(createDatasetMutationDoc, ownerArgs || {}, { + createInput: { datasetType }, + }), { - label: 'Published', - url: `published`, - selected: navigationTab === 'published', - }, - ]; - - interface DatasetTableRow { - title: string; - id: string; - datasetType?: string | null; - created: string; - modified: string; - } + onSuccess: (data) => { + setIsTypeModalOpen(false); + if (data.addDataset.success) { + toast('Dataset created successfully!'); + if (isValidParams && entityType && entitySlug) { + const datasetId = data?.addDataset?.data?.id; + router.push( + `/dashboard/${entityType}/${entitySlug}/dataset/${datasetId}/edit/resources` + ); + } + } else { + const message = + data.addDataset.errors?.fieldErrors?.[0]?.messages[0] ?? + 'Unable to create dataset'; + toast('Error: ' + message); + } + }, + } + ); - interface DatasetTableCell { - row: { original: DatasetTableRow }; - } + const selectStatus = (nextStatus: string) => { + setStatus(nextStatus); + setFilters((current) => withStatusFilter(current, nextStatus)); + }; - const datasetsListColumns = [ + const filterConfig: ColumnFilterConfig[] = [ { - accessorKey: 'title', - header: 'Title', - cell: ({ row }: DatasetTableCell) => - navigationTab === 'published' ? ( - {row.original.title} - ) : ( - - {row.original.title} - - ), + columnId: 'datasetType', + type: 'select', + options: DATASET_TYPE_FILTER_OPTIONS, }, { - accessorKey: 'datasetType', - header: 'Type', - cell: ({ row }: DatasetTableCell) => ( - {row.original.datasetType === 'PROMPT' ? 'Prompt' : 'Data'} + columnId: 'geography', + type: 'select', + options: optionList( + (geographiesQuery.data?.geographies ?? []).map((geography) => ({ + label: geography.name, + value: geography.id, + })) ), }, - { accessorKey: 'created', header: 'Date Created' }, - { accessorKey: 'modified', header: 'Date Modified' }, { - accessorKey: 'delete', - header: 'Delete', - cell: ({ row }: DatasetTableCell) => - navigationTab === 'published' ? ( - - ) : ( - { - DeleteDatasetMutation.mutate({ - datasetId: row.original?.id, - }); - }} - > - Delete - - ), + columnId: 'sector', + type: 'multiSelect', + options: optionList( + (sectorsQuery.data?.sectors ?? []).map((sector) => ({ + label: sector.name, + value: String(sector.id), + })) + ), }, + // { + // columnId: 'Tags', + // type: 'multiSelect', + // options: optionList( + // (tagsQuery.data?.tags ?? []).map((tag) => ({ + // label: tag.value, + // value: tag.value, + // })) + // ), + // }, ]; - const generateTableData = ( - list: { - title: string; - id: string; - datasetType?: string | null; - created?: string | null; - modified?: string | null; - }[] - ) => { - return list.map((item) => { - return { - title: item.title, - id: item.id, - datasetType: item.datasetType, - created: formatDate(item.created ?? null) || '', - modified: formatDate(item.modified ?? null) || '', - }; - }); - }; + const rows = toListingRows(datasetsQuery.data?.datasetsTable.data ?? []); + const emptyLabel = hasListingFilters(filters, status) + ? 'No records match your filters' + : 'No datasets yet'; + + if (!isValidParams || !entityType || !entitySlug) { + return null; + } return ( <> -
- - - { (AllDatasetsQuery.data?.datasets.length ?? 0) > 0 ? ( -
- item.selected)?.label || '' - } - primaryAction={{ - content: 'Add New Dataset', - onAction: () => setIsTypeModalOpen(true), - }} - data-tour="create-dataset" - /> - - -
- ) : AllDatasetsQuery.isLoading ? ( - - ) : ( - setIsTypeModalOpen(true)} /> - )} +
+ setIsTypeModalOpen(true)} + placeholder="Search datasets..." + sortColumns={['title', 'modified']} + filters={filterConfig} + statuses={DATASET_TABLE_STATUSES} + defaultRowCount={DATASET_TABLE_PAGE_SIZE} + labels={{ empty: emptyLabel }} + loading={datasetsQuery.isFetching} + loadError={datasetsQuery.isError && !datasetsQuery.isFetching} + onRetry={() => datasetsQuery.refetch()} + totalRows={datasetsQuery.data?.datasetsTable.totalItemsCount ?? 0} + statusCounts={toStatusCounts( + datasetsQuery.data?.datasetsTable.statusCounts + )} + searchState={search} + onSearchChange={setSearch} + statusState={status} + onStatusChange={selectStatus} + filterState={filters} + sortingState={sorting} + paginationState={pagination} + onFiltersChange={(next) => { + const exact = next.find( + (filter) => + filter.field === 'status' && filter.condition === 'exact' + ); + const nextStatus = + exact?.value === DatasetStatus.Draft || + exact?.value === DatasetStatus.Published + ? exact.value + : status; + setStatus(nextStatus); + setFilters(withStatusFilter(next, nextStatus)); + }} + onSortingChange={setSorting} + onPaginationChange={setPagination} + getRowActions={(row: DatasetListingRow): DataTableRowAction[] => { + const actions: DataTableRowAction[] = []; + const isPublished = row.status === DatasetStatus.Published; - {/* */} + if (isPublished) { + actions.push({ + key: 'unpublish', + icon: IconEyeOff, + color: 'critical', + label: `Unpublish`, + onClick: () => + unpublishDatasetMutation.mutate({ datasetId: row.id }), + alert: { + title: 'Unpublish dataset?', + children: `Unpublishing "${row.title}" will move it back to draft.`, + }, + }); + } else { + actions.push({ + key: 'edit', + icon: IconPencil, + label: `Edit ${row.title}`, + onClick: () => + router.push( + `/dashboard/${entityType}/${entitySlug}/dataset/${row.id}/edit` + ), + }); + } + actions.push({ + key: 'delete', + icon: IconTrash, + label: `Delete`, + destructive: true, + onClick: () => + deleteDatasetMutation.mutate({ datasetId: row.id }), + alert: { + title: 'Delete dataset?', + children: `Deleting "${row.title}" will permanently remove it. This action cannot be undone.`, + }, + }); + return actions; + }} + data-tour="my-datasets" + />
- {/* Dataset Type Selection Modal */} setIsTypeModalOpen(false)} onSelect={(type: DatasetType) => - CreateDatasetMutation.mutate( + createDatasetMutation.mutate( type === 'PROMPT' ? GqlDatasetType.Prompt : GqlDatasetType.Data ) } - isLoading={CreateDatasetMutation.isLoading} + isLoading={createDatasetMutation.isLoading} /> ); diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx index 9f526af9..434a3b44 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx @@ -53,7 +53,7 @@ export default function OrgDashboardLayout({ children }: DashboardLayoutProps) { { title: 'Datasets', href: `/dashboard/${params.entityType}/${params.entitySlug}/dataset`, - icon: 'datasetEdit', + icon: 'dataset', }, ...(params.entityType === 'organization' @@ -66,29 +66,34 @@ export default function OrgDashboardLayout({ children }: DashboardLayoutProps) { ] satisfies SidebarNavItem[]) : []), { - title: 'UseCases', + title: 'Use Cases', href: `/dashboard/${params.entityType}/${params.entitySlug}/usecases`, - icon: 'light', + icon: 'grid', }, { title: 'AI Models', href: `/dashboard/${params.entityType}/${params.entitySlug}/aimodels`, icon: 'light', }, + { + title: 'Publications', + href: `/dashboard/${params.entityType}/${params.entitySlug}/publications`, + icon: 'publication', + }, { title: 'Collaboratives', href: `/dashboard/${params.entityType}/${params.entitySlug}/collaboratives`, icon: 'userGroup', }, { - title: 'Add & Manage Charts', + title: 'Charts', href: `/dashboard/${params.entityType}/${params.entitySlug}/charts`, icon: 'chartBar', }, { title: 'Profile', href: `/dashboard/${params.entityType}/${params.entitySlug}/profile`, - icon: 'setting', + icon: 'user', }, ]; @@ -130,7 +135,7 @@ export default function OrgDashboardLayout({ children }: DashboardLayoutProps) {
void; + showNavigation: boolean; + titlePending?: boolean; + onTitleSave?: (title: string) => void; + titleFieldName?: string; + footer?: ReactNode; + stepperClassName?: string; +} + +export function WizardFrame({ + title, + untitledLabel, + goBackURL, + status, + steps, + currentStep, + onStepClick, + showNavigation, + titlePending, + onTitleSave, + titleFieldName, + footer, + stepperClassName, +}: WizardFrameProps) { + return ( +
+ + +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/WizardHeader.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/WizardHeader.tsx new file mode 100644 index 00000000..321b7aee --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/WizardHeader.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { ReactNode, useState } from 'react'; +import Link from 'next/link'; +import { Button, Icon, Spinner, Text, TextField } from 'opub-ui'; + +import { Icons } from '@/components/icons'; + +export type WizardSaveStatus = 'loading' | 'success'; + +interface WizardHeaderProps { + title: string; + untitledLabel: string; + goBackURL: string; + status: WizardSaveStatus; + titlePending?: boolean; + onTitleSave?: (title: string) => void; + titleFieldName?: string; + footer?: ReactNode; +} + +export function WizardHeader({ + title, + untitledLabel, + goBackURL, + status, + titlePending = false, + onTitleSave, + titleFieldName = 'providerTitle', + footer, +}: WizardHeaderProps) { + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(title); + const [syncedTitle, setSyncedTitle] = useState(title); + if (title !== syncedTitle) { + setSyncedTitle(title); + setDraftTitle(title); + } + + const handleSave = () => { + const next = draftTitle.trim(); + if (next && next !== title) { + onTitleSave?.(next); + } + setEditing(false); + }; + + return ( +
+
+
+ + + + {editing ? ( +
+ + +
+ ) : titlePending ? ( +
+ ) : ( + <> + + {title || untitledLabel} + + {onTitleSave ? ( + + ) : null} + + )} +
+
+ {status === 'loading' ? ( + + ) : ( + + )} + + {status === 'loading' ? 'Saving…' : 'All changes saved'} + +
+
+ {footer} +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status.tsx new file mode 100644 index 00000000..5b906a45 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useRef, useState } from 'react'; + +export type ProviderSaveStatus = 'loading' | 'success'; +export type BeforeNavigateHandler = (() => Promise | void) | null; + +export function useProviderEditState() { + const [status, setStatus] = useState('success'); + const [stepShowErrors, setStepShowErrors] = useState(false); + const beforeNavigateHandlerRef = useRef(null); + + const registerBeforeNavigateHandler = (handler: BeforeNavigateHandler) => { + beforeNavigateHandlerRef.current = handler; + }; + + const runBeforeNavigateHandler = async () => { + await beforeNavigateHandlerRef.current?.(); + }; + + return { + status, + setStatus, + registerBeforeNavigateHandler, + runBeforeNavigateHandler, + stepShowErrors, + setStepShowErrors, + }; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/dataset.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/listing.module.scss similarity index 100% rename from app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/dataset.module.scss rename to app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/listing.module.scss diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/rich-text.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/rich-text.ts new file mode 100644 index 00000000..376fbf31 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/rich-text.ts @@ -0,0 +1,4 @@ +export function isRichTextEmpty(html?: string | null): boolean { + if (!html) return true; + return html.replace(/<(.|\n)*?>/g, '').trim().length === 0; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/step-content.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/step-content.tsx new file mode 100644 index 00000000..e1876294 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/step-content.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { ReactNode, useEffect } from 'react'; +import { useStepperStep } from 'opub-ui'; + +export function StepErrorSync({ + setStepShowErrors, +}: { + setStepShowErrors: (show: boolean) => void; +}) { + const { showErrors } = useStepperStep(); + + useEffect(() => { + setStepShowErrors(showErrors); + }, [showErrors, setStepShowErrors]); + + return null; +} + +export function wizardStepContent( + isActive: boolean, + children: ReactNode, + setStepShowErrors: (show: boolean) => void +) { + if (!isActive) return null; + return ( + <> + + {children} + + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/DetailsStep.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/DetailsStep.tsx new file mode 100644 index 00000000..2d916e60 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/DetailsStep.tsx @@ -0,0 +1,516 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { FetchUsers } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/query'; +import { DatasetLicense } from '@/gql/generated/graphql'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Combobox, + SectionCard, + Select, + Spinner, + Text, + TextField, + toast, +} from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { + EntityRowPicker, + type EntityRow, +} from '../../usecases/edit/components/EntityRowPicker'; +import styles from '../../usecases/edit/edit.module.scss'; +import { usePublicationDraft } from '../context'; +import { + detailsErrors, + emptyDetails, + errorText, + isDetailsComplete, + isHttpUrl, + LICENSE_OPTIONS, + mutationMessage, + publicationWriteInput, + type DetailsDraft, + type TagOption, +} from '../model'; +import { + addPublicationFileMutation, + addPublicationYoutubeMutation, + createPublicationMutation, + publicationEditorQuery, + publicationReferenceQuery, + updatePublicationMutation, +} from '../queries'; + +function toTagOptions(value: string | TagOption[]): TagOption[] { + return Array.isArray(value) ? value : []; +} + +function fileUrl(url?: string | null) { + if (!url) return ''; + if (url.startsWith('http')) return url; + return `${process.env.NEXT_PUBLIC_BACKEND_URL}/${url.replace('/code/files/', '')}`; +} + +export function DetailsStep({ publicationId }: { publicationId?: string }) { + const params = useParams<{ entityType: string; entitySlug: string }>(); + const headers = useMemo( + () => ({ [params.entityType]: params.entitySlug }), + [params.entityType, params.entitySlug] + ); + const queryClient = useQueryClient(); + const draft = usePublicationDraft(); + const { + details: draftDetails, + setDetails: setDraftDetails, + pendingBlocks, + setPendingBlocks, + showErrors, + setShowErrors, + setDetailsReady, + setStatus, + registerBeforeNavigateHandler, + } = draft; + + const [editedForm, setEditedForm] = useState<{ + id: string; + form: DetailsDraft; + } | null>(null); + const [userSearch, setUserSearch] = useState(''); + + const editorQuery = useQuery( + ['publication_editor', publicationId], + () => + GraphQL(publicationEditorQuery, headers, { + publicationId: publicationId ?? '', + }), + { enabled: Boolean(publicationId) } + ); + + const referenceQuery = useQuery(['publication_form_reference'], () => + GraphQL(publicationReferenceQuery, headers) + ); + + const usersQuery = useQuery( + ['publication_contributor_search', userSearch], + () => GraphQL(FetchUsers, headers, { limit: 8, searchTerm: userSearch }), + { enabled: userSearch.trim().length > 0 } + ); + + const publication = editorQuery.data?.getPublication; + const serverForm: DetailsDraft | null = publication + ? { + title: publication.title ?? '', + description: publication.description ?? '', + publicationDate: String(publication.publicationDate ?? '').slice(0, 10), + license: publication.license ?? 'CC_BY_4_0_ATTRIBUTION', + resourceTypeId: publication.resourceType?.id ?? '', + sectors: (publication.sectors ?? []).map((sector) => ({ + label: sector.name, + value: sector.id, + })), + geographies: (publication.geographies ?? []).map((geography) => ({ + label: geography.name, + value: String(geography.id), + })), + externalSourceLink: publication.externalSourceLink ?? '', + contributors: (publication.authors ?? []).map((name, index) => ({ + id: `author-${index}-${name}`, + name, + })), + } + : null; + const form = publicationId + ? editedForm?.id === publicationId + ? editedForm.form + : (serverForm ?? emptyDetails()) + : draftDetails; + const formRef = useRef(form); + const pendingRef = useRef(pendingBlocks); + const editedRef = useRef(false); + const saveChain = useRef(Promise.resolve()); + + useEffect(() => { + editedRef.current = false; + }, [publicationId]); + + useEffect(() => { + pendingRef.current = pendingBlocks; + }, [pendingBlocks]); + + useEffect(() => { + if (!editedRef.current) formRef.current = form; + setDetailsReady(isDetailsComplete(formRef.current)); + }, [form, setDetailsReady]); + + const updateForm = (patch: Partial) => { + // Merge onto the ref, not the last render. A date picker and Continue + // happen in one turn, and a second edit would otherwise replace the + // contributor list with the previous form. + const next = { ...formRef.current, ...patch }; + editedRef.current = true; + formRef.current = next; + setDetailsReady(isDetailsComplete(next)); + if (publicationId) { + setEditedForm({ id: publicationId, form: next }); + return next; + } + setDraftDetails(next); + return next; + }; + + const persist = useCallback(() => { + const run = saveChain.current + .catch(() => undefined) + .then(async () => { + const current = formRef.current; + if ( + current.externalSourceLink.trim() && + !isHttpUrl(current.externalSourceLink) + ) { + setShowErrors(true); + toast('Enter a valid URL.'); + throw new Error('invalid'); + } + + const written = publicationWriteInput(current); + const input = { + ...written, + license: (written.license || null) as DatasetLicense | null, + }; + + setStatus('loading'); + let id = publicationId; + if (!id) { + const created = await GraphQL(createPublicationMutation, headers, { + input, + }); + const message = mutationMessage( + created.createPublication, + 'Could not create the publication.' + ); + if (message || !created.createPublication?.data?.id) { + throw new Error(message || 'Could not create the publication.'); + } + id = created.createPublication.data.id; + } else { + const updated = await GraphQL(updatePublicationMutation, headers, { + input: { id, ...input }, + }); + const message = mutationMessage( + updated.updatePublication, + 'Could not save changes.' + ); + if (message) throw new Error(message); + } + + setStatus('success'); + toast('Publication updated successfully', { + id: 'publication-save-success', + }); + await queryClient.invalidateQueries({ + queryKey: ['publication_editor', id], + }); + return id; + }); + saveChain.current = run.then( + () => undefined, + () => undefined + ); + return run.catch((error: unknown) => { + setStatus('unsaved'); + if (error instanceof Error && error.message === 'invalid') throw error; + toast(errorText(error, 'Could not save the publication.')); + throw error; + }); + }, [headers, publicationId, queryClient, setShowErrors, setStatus]); + + const saveNow = () => { + void persist().catch(() => undefined); + }; + + useEffect(() => { + registerBeforeNavigateHandler(async () => { + const id = await persist(); + if (!id) throw new Error('Could not save the publication.'); + + try { + const queued = [...pendingRef.current]; + for (const block of queued) { + if (block.kind === 'file') { + const uploaded = await GraphQL( + addPublicationFileMutation, + headers, + { + publicationId: id, + file: block.file, + title: block.title, + description: block.description || null, + } + ); + const message = mutationMessage( + uploaded.addPublicationFileBlock, + `${block.file.name} could not be uploaded.` + ); + if (message) throw new Error(message); + } else { + const uploaded = await GraphQL( + addPublicationYoutubeMutation, + headers, + { + publicationId: id, + youtubeUrl: block.url, + title: block.title || null, + description: block.description || null, + } + ); + const message = mutationMessage( + uploaded.addPublicationYoutubeBlock, + 'A YouTube video could not be saved.' + ); + if (message) throw new Error(message); + } + setPendingBlocks((items) => + items.filter((item) => item.id !== block.id) + ); + } + } catch (error) { + setStatus('unsaved'); + toast(errorText(error, 'Some files could not be uploaded.')); + await queryClient.invalidateQueries({ + queryKey: ['publication_editor', id], + }); + return { id, step: 1 }; + } + + setStatus('success'); + setShowErrors(false); + await queryClient.invalidateQueries({ + queryKey: ['publication_editor', id], + }); + return { id }; + }); + return () => registerBeforeNavigateHandler(null); + }, [ + persist, + headers, + queryClient, + registerBeforeNavigateHandler, + setPendingBlocks, + setShowErrors, + ]); + + if (publicationId && editorQuery.isLoading) { + return ( +
+ +
+ ); + } + + const errors = showErrors ? detailsErrors(form) : {}; + const resourceTypes = + referenceQuery.data?.resourceTypes?.map((item) => ({ + label: item.name, + value: item.id, + })) ?? []; + const sectorOptions = + referenceQuery.data?.sectors?.map((item) => ({ + label: item.name, + value: item.id, + })) ?? []; + const geographyOptions = + referenceQuery.data?.geographies?.map((item) => ({ + label: item.name, + value: String(item.id), + })) ?? []; + + const selectedContributors: EntityRow[] = form.contributors.map((item) => ({ + id: item.id, + title: item.name, + subtitle: item.username, + imageUrl: item.imageUrl, + })); + const contributorOptions: EntityRow[] = + usersQuery.data?.searchUsers?.map((item) => ({ + id: item.id, + title: item.fullName, + subtitle: item.username, + imageUrl: fileUrl(item.profilePicture?.url) || undefined, + })) ?? []; + + return ( +
+ +
+ updateForm({ title: value })} + onBlur={saveNow} + /> + updateForm({ description: value })} + onBlur={saveNow} + /> + { + updateForm({ publicationDate: value }); + saveNow(); + }} + /> + updateForm({ externalSourceLink: value })} + onBlur={saveNow} + /> +
+
+ + + 0 && usersQuery.isFetching} + onSearch={setUserSearch} + onAdd={(item) => { + const current = formRef.current.contributors; + if (current.some((contributor) => contributor.id === item.id)) { + return; + } + updateForm({ + contributors: [ + ...current, + { + id: item.id, + name: item.title, + username: item.subtitle, + imageUrl: item.imageUrl || undefined, + }, + ], + }); + saveNow(); + }} + onRemove={(id) => { + updateForm({ + contributors: formRef.current.contributors.filter( + (contributor) => contributor.id !== id + ), + }); + saveNow(); + }} + /> + {errors.contributors ? ( +
+ + {errors.contributors} + +
+ ) : null} +
+ + +
+
+ { + updateForm({ license: value }); + saveNow(); + }} + /> +
+ { + updateForm({ sectors: toTagOptions(value) }); + saveNow(); + }} + /> + { + updateForm({ geographies: toTagOptions(value) }); + saveNow(); + }} + /> +
+
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/FilesStep.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/FilesStep.tsx new file mode 100644 index 00000000..2415cd35 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/FilesStep.tsx @@ -0,0 +1,750 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button, DropZone, FileCard, Icon, Spinner, Text, TextField, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { Icons } from '@/components/icons'; +import { usePublicationDraft } from '../context'; +import { + errorText, + fileDisplayTitle, + formatFileSize, + formatUploadedAt, + isAllowedPublicationFile, + MAX_PUBLICATION_FILE_BYTES, + mutationMessage, + PUBLICATION_FILE_ACCEPT, + PUBLICATION_FILE_EXTENSIONS, + youtubeVideoId, + type PendingBlock, +} from '../model'; +import { + addPublicationFileMutation, + addPublicationYoutubeMutation, + createPublicationMutation, + publicationEditorQuery, + removePublicationBlockMutation, + reorderPublicationBlocksMutation, + updatePublicationBlockMutation, + type EditorBlock, +} from '../queries'; + +function blockKind(blockType: string) { + return blockType.toUpperCase().includes('YOUTUBE') ? 'video' : 'file'; +} + +function displayTitle(block: { + title?: string | null; + blockType?: string; + fileName?: string | null; + kind?: 'file' | 'video'; +}) { + if (block.title?.trim()) return block.title.trim(); + const video = + block.kind === 'video' || + (block.blockType ? blockKind(block.blockType) === 'video' : false); + return video ? 'YouTube video' : block.fileName || 'Untitled file'; +} + +function editorHref( + entityType: string, + entitySlug: string, + id: string, + step: string +) { + return `/dashboard/${entityType}/${entitySlug}/publications/edit/${id}/${step}`; +} + +export function FilesStep({ publicationId }: { publicationId?: string }) { + const router = useRouter(); + const params = useParams<{ entityType: string; entitySlug: string }>(); + const headers = { [params.entityType]: params.entitySlug }; + const queryClient = useQueryClient(); + const { + details, + pendingBlocks, + setPendingBlocks, + setStatus, + registerBeforeNavigateHandler, + } = usePublicationDraft(); + const [showVideoInput, setShowVideoInput] = useState(false); + const [videoUrl, setVideoUrl] = useState(''); + const detailsRef = useRef(details); + const pendingRef = useRef(pendingBlocks); + const draftIdRef = useRef(publicationId ?? null); + const ensurePromise = useRef | null>(null); + + useEffect(() => { + detailsRef.current = details; + pendingRef.current = pendingBlocks; + if (publicationId) draftIdRef.current = publicationId; + }, [details, pendingBlocks, publicationId]); + + const blocksQuery = useQuery( + ['publication_editor', publicationId], + () => + GraphQL(publicationEditorQuery, headers, { + publicationId: publicationId ?? '', + }), + { enabled: Boolean(publicationId) } + ); + + const serverBlocks: EditorBlock[] = [ + ...(blocksQuery.data?.getPublication?.blocks ?? []), + ].sort((a, b) => a.position - b.position); + + const invalidate = (id?: string) => + queryClient.invalidateQueries({ + queryKey: ['publication_editor', id ?? publicationId], + }); + + const onError = (error: unknown) => { + setStatus('unsaved'); + toast(errorText(error, 'Something went wrong.')); + }; + + const ensureDraft = async () => { + if (publicationId) return publicationId; + if (draftIdRef.current) return draftIdRef.current; + if (!ensurePromise.current) { + ensurePromise.current = (async () => { + const title = detailsRef.current.title.trim(); + const created = await GraphQL(createPublicationMutation, headers, { + input: title ? { title } : {}, + }); + const message = mutationMessage( + created.createPublication, + 'Could not create the publication.' + ); + const id = created.createPublication?.data?.id; + if (message || !id) { + ensurePromise.current = null; + throw new Error(message || 'Could not create the publication.'); + } + draftIdRef.current = id; + return id; + })(); + } + return ensurePromise.current; + }; + + const uploadFile = async ( + id: string, + file: File, + title: string, + description: string + ) => { + const uploaded = await GraphQL(addPublicationFileMutation, headers, { + publicationId: id, + file, + title, + description: description || null, + }); + const message = mutationMessage( + uploaded.addPublicationFileBlock, + `${file.name} could not be uploaded.` + ); + if (message) throw new Error(message); + }; + + const uploadVideo = async ( + id: string, + url: string, + title: string, + description: string + ) => { + const uploaded = await GraphQL(addPublicationYoutubeMutation, headers, { + publicationId: id, + youtubeUrl: url, + title: title || null, + description: description || null, + }); + const message = mutationMessage( + uploaded.addPublicationYoutubeBlock, + 'Enter a valid YouTube URL.' + ); + if (message) throw new Error(message); + }; + + const uploadPending = async (id: string, block: PendingBlock) => { + if (block.kind === 'file') { + await uploadFile(id, block.file, block.title, block.description); + return; + } + await uploadVideo(id, block.url, block.title, block.description); + }; + + const saveBeforeNavigateRef = useRef<() => Promise<{ id: string }>>( + async () => ({ id: publicationId ?? '' }) + ); + useEffect(() => { + saveBeforeNavigateRef.current = async () => { + const queued = [...pendingRef.current]; + setStatus('loading'); + try { + const id = await ensureDraft(); + for (const block of queued) { + await uploadPending(id, block); + setPendingBlocks((items) => + items.filter((item) => item.id !== block.id) + ); + } + setStatus('success'); + return { id }; + } catch (error) { + setStatus('unsaved'); + toast(errorText(error, 'Could not save the publication.')); + throw error; + } + }; + }); + + useEffect(() => { + registerBeforeNavigateHandler(() => saveBeforeNavigateRef.current()); + return () => registerBeforeNavigateHandler(null); + }, [registerBeforeNavigateHandler]); + + const fileMutation = useMutation( + (file: File) => + GraphQL(addPublicationFileMutation, headers, { + publicationId: publicationId ?? draftIdRef.current ?? '', + file, + title: fileDisplayTitle(file.name), + }), + { + onMutate: () => setStatus('loading'), + onSuccess: (result) => { + const message = mutationMessage( + result.addPublicationFileBlock, + 'File could not be uploaded.' + ); + if (message) { + setStatus('unsaved'); + toast(message); + return; + } + setStatus('success'); + void invalidate(draftIdRef.current ?? undefined); + }, + onError, + } + ); + + const videoMutation = useMutation( + (url: string) => + GraphQL(addPublicationYoutubeMutation, headers, { + publicationId: publicationId ?? draftIdRef.current ?? '', + youtubeUrl: url, + }), + { + onMutate: () => setStatus('loading'), + onSuccess: (result) => { + const message = mutationMessage( + result.addPublicationYoutubeBlock, + 'Enter a valid YouTube URL.' + ); + if (message) { + setStatus('unsaved'); + toast(message); + return; + } + setVideoUrl(''); + setShowVideoInput(false); + setStatus('success'); + void invalidate(draftIdRef.current ?? undefined); + }, + onError, + } + ); + + const removeMutation = useMutation( + (blockId: string) => + GraphQL(removePublicationBlockMutation, headers, { blockId }), + { + onMutate: () => setStatus('loading'), + onSuccess: (result) => { + const message = mutationMessage( + result.removePublicationBlock, + 'Could not remove that file.' + ); + if (message) { + setStatus('unsaved'); + toast(message); + return; + } + setStatus('success'); + void invalidate(); + }, + onError, + } + ); + + const reorderMutation = useMutation( + (blockIds: string[]) => + GraphQL(reorderPublicationBlocksMutation, headers, { + publicationId: publicationId ?? '', + blockIds, + }), + { + onMutate: () => setStatus('loading'), + onSuccess: (result) => { + const message = mutationMessage( + result.reorderPublicationBlocks, + 'Could not reorder files.' + ); + if (message) { + setStatus('unsaved'); + toast(message); + return; + } + setStatus('success'); + void invalidate(); + }, + onError, + } + ); + + const renameMutation = useMutation( + (variables: { blockId: string; title?: string; description?: string }) => + GraphQL(updatePublicationBlockMutation, headers, variables), + { + onMutate: () => setStatus('loading'), + onSuccess: (result) => { + const message = mutationMessage( + result.updatePublicationBlock, + 'Could not update that file.' + ); + if (message) { + setStatus('unsaved'); + toast(message); + return; + } + setStatus('success'); + toast('Publication updated successfully', { + id: 'publication-save-success', + }); + void invalidate(); + }, + onError, + } + ); + + const openSavedDraft = (id: string) => { + if (publicationId) return; + router.replace(editorHref(params.entityType, params.entitySlug, id, 'files')); + }; + + const addPendingFiles = (files: File[]) => { + const accepted: PendingBlock[] = []; + files.forEach((file) => { + if (!isAllowedPublicationFile(file)) { + toast(`${file.name} is not a supported file type.`); + return; + } + if (file.size > MAX_PUBLICATION_FILE_BYTES) { + toast(`${file.name} is larger than the 50 MB limit.`); + return; + } + accepted.push({ + id: `pending-${file.name}-${file.size}-${file.lastModified}`, + kind: 'file', + file, + addedAt: new Date().toISOString(), + title: fileDisplayTitle(file.name), + description: '', + }); + }); + if (accepted.length === 0) return; + setPendingBlocks((current) => [...current, ...accepted]); + setStatus('unsaved'); + }; + + const handleDrop = async (_dropped: File[], acceptedFiles: File[]) => { + const accepted = acceptedFiles.filter((file) => { + if (!isAllowedPublicationFile(file)) { + toast(`${file.name} is not a supported file type.`); + return false; + } + if (file.size > MAX_PUBLICATION_FILE_BYTES) { + toast(`${file.name} is larger than the 50 MB limit.`); + return false; + } + return true; + }); + if (accepted.length === 0) return; + + if (publicationId) { + accepted.forEach((file) => fileMutation.mutate(file)); + return; + } + + setStatus('loading'); + try { + const id = await ensureDraft(); + for (const file of accepted) { + await uploadFile(id, file, fileDisplayTitle(file.name), ''); + } + setStatus('success'); + openSavedDraft(id); + void invalidate(id); + } catch (error) { + addPendingFiles(accepted); + onError(error); + } + }; + + const addVideo = async () => { + const videoId = youtubeVideoId(videoUrl); + if (!videoId) { + toast('Enter a valid YouTube URL.'); + return; + } + const url = videoUrl.trim(); + if (publicationId) { + videoMutation.mutate(url); + return; + } + setStatus('loading'); + try { + const id = await ensureDraft(); + await uploadVideo(id, url, '', ''); + setVideoUrl(''); + setShowVideoInput(false); + setStatus('success'); + openSavedDraft(id); + void invalidate(id); + } catch (error) { + setPendingBlocks((current) => [ + ...current, + { + id: `pending-video-${videoId}-${Date.now()}`, + kind: 'video', + url, + videoId, + addedAt: new Date().toISOString(), + title: '', + description: '', + }, + ]); + onError(error); + } + }; + + const movePending = (index: number, direction: -1 | 1) => { + setPendingBlocks((current) => { + const next = [...current]; + const target = index + direction; + if (target < 0 || target >= next.length) return current; + const [item] = next.splice(index, 1); + next.splice(target, 0, item); + return next; + }); + setStatus('unsaved'); + }; + + const moveServer = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= serverBlocks.length) return; + const ids = serverBlocks.map((block) => block.id); + const [item] = ids.splice(index, 1); + ids.splice(target, 0, item); + reorderMutation.mutate(ids); + }; + + const updatePending = ( + id: string, + patch: Partial> + ) => { + setPendingBlocks((current) => + current.map((item) => { + if (item.id !== id) return item; + if (item.kind === 'file') return { ...item, ...patch }; + return { ...item, ...patch }; + }) + ); + setStatus('unsaved'); + }; + + const visiblePending = publicationId ? [] : pendingBlocks; + const itemCount = serverBlocks.length + visiblePending.length; + + if (publicationId && blocksQuery.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ Upload Files +
+ +
+ + + Drag and drop files here, or click to browse. + + + Supported file types: + +
+ {PUBLICATION_FILE_EXTENSIONS.map((type) => ( + + {type.toUpperCase()} + + ))} +
+ + + Maximum file size limit: 50MB + +
+
+
+
+ + {showVideoInput ? ( +
+
+ +
+ +
+ ) : null} +
+
+ +
+
+ Files ({itemCount}) + {itemCount > 0 ? ( + + {itemCount} {itemCount === 1 ? 'Item' : 'Items'} Ready + + ) : null} +
+ {itemCount === 0 ? ( +
+ No files uploaded yet. +
+ ) : ( +
+ {serverBlocks.map((block, index) => { + const video = blockKind(block.blockType) === 'video'; + return ( + moveServer(index, -1)} + onMoveDown={() => moveServer(index, 1)} + onRename={(title) => + renameMutation.mutate({ blockId: block.id, title }) + } + onDescription={(description) => + renameMutation.mutate({ blockId: block.id, description }) + } + onRemove={() => removeMutation.mutate(block.id)} + /> + ); + })} + {visiblePending.map((block, index) => ( + movePending(index, -1)} + onMoveDown={() => movePending(index, 1)} + onRename={(title) => updatePending(block.id, { title })} + onDescription={(description) => + updatePending(block.id, { description }) + } + onRemove={() => + setPendingBlocks((current) => + current.filter((item) => item.id !== block.id) + ) + } + /> + ))} +
+ )} +
+
+ ); +} + +function fileExtensionLabel(name: string) { + return name.split('.').pop()?.toUpperCase() ?? 'FILE'; +} + +function BlockRow({ + title, + description, + format, + size, + uploadedAt, + originalName, + thumbnail, + readyLabel = 'Ready', + isFirst, + isLast, + onMoveUp, + onMoveDown, + onRename, + onDescription, + onRemove, +}: { + title: string; + description: string; + format: string; + size: string; + uploadedAt: string; + originalName?: string; + thumbnail?: string; + readyLabel?: string; + isFirst: boolean; + isLast: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + onRename: (title: string) => void; + onDescription: (description: string) => void; + onRemove: () => void; +}) { + const [draftDescription, setDraftDescription] = useState(description); + const [syncedDescription, setSyncedDescription] = useState(description); + if (description !== syncedDescription) { + setSyncedDescription(description); + setDraftDescription(description); + } + + return ( +
+
+
+
+ + {thumbnail ? ( + // YouTube thumbnails are not in the image allow-list. + // eslint-disable-next-line @next/next/no-img-element + + ) : null} + { + if (draftDescription.trim() !== description.trim()) { + onDescription(draftDescription.trim()); + } + }} + /> +
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/ReviewStep.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/ReviewStep.tsx new file mode 100644 index 00000000..7c64aa97 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/ReviewStep.tsx @@ -0,0 +1,385 @@ +'use client'; + +import { type ReactNode } from 'react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { + IconAlertTriangle, + IconCircleCheck, + IconFile, + IconPlayerPlay, + IconSend, +} from '@tabler/icons-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Button, SectionCard, Spinner, Tag, Text, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { editAction } from '../../usecases/edit/components/ContentBlocksRenderer'; +import { usePublicationDraft } from '../context'; +import { errorText, licenseLabel, mutationMessage } from '../model'; +import { + publicationEditorQuery, + publishPublicationMutation, + unpublishPublicationMutation, +} from '../queries'; + +function ReviewRow({ + label, + children, + last = false, +}: { + label: string; + children: ReactNode; + last?: boolean; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +export function ReviewStep({ publicationId }: { publicationId: string }) { + const router = useRouter(); + const params = useParams<{ entityType: string; entitySlug: string }>(); + const { setStatus } = usePublicationDraft(); + const stepBase = `/dashboard/${params.entityType}/${params.entitySlug}/publications/edit/${publicationId}`; + + const reviewQuery = useQuery( + ['publication_editor', publicationId], + () => + GraphQL( + publicationEditorQuery, + { [params.entityType]: params.entitySlug }, + { publicationId } + ), + { refetchOnMount: 'always' } + ); + + const publication = reviewQuery.data?.getPublication; + const published = publication?.status === 'PUBLISHED'; + const headers = { [params.entityType]: params.entitySlug }; + const listHref = `/dashboard/${params.entityType}/${params.entitySlug}/publications`; + + const publish = useMutation( + () => + GraphQL(publishPublicationMutation, headers, { publicationId }), + { + onSuccess: (result) => { + const message = mutationMessage( + result.publishPublication, + 'Could not publish this publication.' + ); + if (message) { + toast(message); + setStatus('unsaved'); + return; + } + toast('Publication published'); + setStatus('success'); + router.push(`${listHref}?tab=published`); + }, + onError: (error: unknown) => { + toast(errorText(error, 'Could not publish this publication.')); + setStatus('unsaved'); + }, + } + ); + + const unpublish = useMutation( + () => + GraphQL(unpublishPublicationMutation, headers, { publicationId }), + { + onSuccess: (result) => { + const message = mutationMessage( + result.unpublishPublication, + 'Could not unpublish this publication.' + ); + if (message) { + toast(message); + setStatus('unsaved'); + return; + } + toast('Publication unpublished'); + setStatus('success'); + router.push(`${listHref}?tab=drafts`); + }, + onError: (error: unknown) => { + toast(errorText(error, 'Could not unpublish this publication.')); + setStatus('unsaved'); + }, + } + ); + + const issues: string[] = []; + if (!publication?.title?.trim()) issues.push('Enter a resource name.'); + if (!publication?.description?.trim()) issues.push('Enter a description.'); + if ((publication?.authors?.length ?? 0) === 0) { + issues.push('Add at least one contributor.'); + } + if (!publication?.publicationDate) issues.push('Enter a date.'); + if (!publication?.license) issues.push('Select usage rights.'); + if (!publication?.resourceType?.id) issues.push('Select a resource type.'); + if ((publication?.sectors?.length ?? 0) === 0) { + issues.push('Select at least one sector.'); + } + if ((publication?.geographies?.length ?? 0) === 0) { + issues.push('Select at least one geography.'); + } + const ready = issues.length === 0; + const blocks = [...(publication?.blocks ?? [])].sort( + (a, b) => a.position - b.position + ); + + if (reviewQuery.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + Review & Publish + +
+ + Check your information before making this content available + publicly. + +
+
+ +
+
+ {ready ? ( + + ) : ( + + )} + + {ready ? 'Ready to publish' : 'Needs attention'} + +
+
+ + {ready + ? 'Everything required to publish is in place.' + : issues.join(' ')} + +
+ {!ready ? ( +
+ +
+ ) : null} +
+ + + router.push(`${stepBase}/details`) + )} + > +
+ + {publication?.title || '—'} + + + {publication?.description || '—'} + + + {(publication?.authors?.length ?? 0) > 0 ? ( +
+ {publication?.authors?.map((name) => ( + + {name} + + ))} +
+ ) : ( + — + )} +
+ + {publication?.publicationDate || '—'} + + + {publication?.resourceType?.name || '—'} + + + + {publication?.sectors?.map((sector) => sector.name).join(', ') || + '—'} + + + + + {publication?.geographies + ?.map((item) => item.name) + .join(', ') || '—'} + + + + {licenseLabel(publication?.license) || '—'} + + + {publication?.externalSourceLink ? ( + + {publication.externalSourceLink} + + ) : ( + — + )} + +
+
+ + router.push(`${stepBase}/files`))} + > + {blocks.length === 0 ? ( + No content added yet. + ) : ( +
+ {blocks.map((block) => { + const isVideo = block.blockType.toUpperCase().includes('YOUTUBE'); + const label = + block.title?.trim() || + (isVideo ? 'YouTube video' : block.fileName || 'Untitled file'); + const format = isVideo + ? '' + : block.fileFormat?.replace('.', '').toUpperCase(); + const videoHref = + block.youtubeUrl || + (block.youtubeVideoId + ? `https://www.youtube.com/watch?v=${block.youtubeVideoId}` + : ''); + return ( +
+
+ {isVideo ? ( + + ) : ( + + )} + {isVideo && videoHref ? ( + + {label} + + ) : ( + {label} + )} +
+ {format ? ( + + + {format} + + + ) : null} +
+ ); + })} +
+ )} +
+ +
+ + {published + ? 'This publication is published and available publicly.' + : 'This publication is not yet published. Publish it to make it available publicly.'} + + +
+ +
+ + +
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/WizardHeader.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/WizardHeader.tsx new file mode 100644 index 00000000..77ca890d --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/WizardHeader.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Button, Icon, Spinner, Text, TextField } from 'opub-ui'; + +import { Icons } from '@/components/icons'; + +interface WizardHeaderProps { + title: string; + goBackURL: string; + status: 'unsaved' | 'loading' | 'success'; + titlePending?: boolean; + onTitleSave?: (title: string) => void; +} + +export function WizardHeader({ + title, + goBackURL, + status, + titlePending = false, + onTitleSave, +}: WizardHeaderProps) { + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(title); + + const handleSave = () => { + const next = draftTitle.trim(); + if (next && next !== title) { + onTitleSave?.(next); + } + setEditing(false); + }; + + const saved = status === 'success'; + + return ( +
+
+
+ + + + {editing ? ( +
+ + +
+ ) : titlePending ? ( +
+ ) : ( + <> + + {title || 'Untitled Publication'} + + {onTitleSave ? ( + + ) : null} + + )} +
+
+ {status === 'loading' ? ( + + ) : saved ? ( + + ) : null} + + {status === 'loading' + ? 'Saving…' + : saved + ? 'All changes saved' + : 'Unsaved changes'} + +
+
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/WizardShell.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/WizardShell.tsx new file mode 100644 index 00000000..1dde9b8f --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/components/WizardShell.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { ReactNode, useEffect } from 'react'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import { + IconClipboardCheck, + IconCloudUpload, + IconFileDescription, +} from '@tabler/icons-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Stepper, toast, useStepperStep } from 'opub-ui'; +import type { StepperItem } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { usePublicationDraft } from '../context'; +import styles from '../edit.module.scss'; +import { errorText, isDetailsComplete } from '../model'; +import { + publicationEditorQuery, + updatePublicationMutation, +} from '../queries'; +import { WizardHeader } from './WizardHeader'; + +const STEP_BY_PATH: Record = { + files: 1, + details: 2, + publish: 3, +}; + +const PATH_BY_STEP: Record = { + 1: 'files', + 2: 'details', + 3: 'publish', +}; + +const layoutList = ['files', 'details', 'publish']; + +function StepErrorSync() { + const { showErrors } = useStepperStep(); + const { setShowErrors } = usePublicationDraft(); + + useEffect(() => { + setShowErrors(showErrors); + }, [showErrors, setShowErrors]); + + return null; +} + +export function PublicationWizard({ + mode, + children, +}: { + mode: 'new' | 'edit'; + children: ReactNode; +}) { + const router = useRouter(); + const pathName = usePathname(); + const params = useParams<{ + entityType: string; + entitySlug: string; + id?: string; + }>(); + const queryClient = useQueryClient(); + const { + details, + detailsReady, + setDetails, + pendingBlocks, + status, + setStatus, + runBeforeNavigateHandler, + } = usePublicationDraft(); + + const publicationId = mode === 'edit' ? params.id : undefined; + const pathItem = layoutList.find((item) => pathName.includes(`/${item}`)); + const currentStep = pathItem ? STEP_BY_PATH[pathItem] : 1; + + const editorQuery = useQuery( + ['publication_editor', publicationId], + () => + GraphQL( + publicationEditorQuery, + { [params.entityType]: params.entitySlug }, + { publicationId: publicationId ?? '' } + ), + { enabled: Boolean(publicationId) } + ); + + const publication = editorQuery.data?.getPublication; + const title = mode === 'new' ? details.title : (publication?.title ?? ''); + const completionReady = mode === 'new' || editorQuery.isFetched; + const detailsComplete = + (mode === 'new' + ? isDetailsComplete(details) + : Boolean( + publication?.title?.trim() && + publication.description?.trim() && + (publication.authors?.length ?? 0) > 0 && + publication.publicationDate && + publication.license && + publication.resourceType?.id && + (publication.sectors?.length ?? 0) > 0 && + (publication.geographies?.length ?? 0) > 0 + )) || detailsReady; + const hasBlocks = + (publication?.blocks?.length ?? 0) > 0 || pendingBlocks.length > 0; + + const { mutate: saveTitle } = useMutation( + (nextTitle: string) => + GraphQL( + updatePublicationMutation, + { [params.entityType]: params.entitySlug }, + { input: { id: publicationId ?? '', title: nextTitle } } + ), + { + onMutate: () => setStatus('loading'), + onSuccess: (result) => { + const payload = result.updatePublication; + if (!payload?.success) { + setStatus('unsaved'); + toast('Could not update the publication name.'); + return; + } + setStatus('success'); + toast('Publication updated successfully', { + id: 'publication-save-success', + }); + void editorQuery.refetch(); + void queryClient.invalidateQueries({ + queryKey: ['publication_editor', publicationId], + }); + }, + onError: (error: unknown) => { + setStatus('unsaved'); + toast(errorText(error, 'Could not update the publication name.')); + }, + } + ); + + if (!pathItem) { + return <>{children}; + } + + const goBackURL = `/dashboard/${params.entityType}/${params.entitySlug}/publications`; + + const handleStepClick = (step: number) => { + const nextPath = PATH_BY_STEP[step]; + if (!nextPath || nextPath === pathItem) return; + void runBeforeNavigateHandler() + .then((result) => { + const id = result?.id || publicationId; + const resolvedStep = result?.step ?? step; + const resolvedPath = PATH_BY_STEP[resolvedStep]; + if (!resolvedPath) return; + if (resolvedPath === 'publish' && !id) return; + const base = id + ? `/dashboard/${params.entityType}/${params.entitySlug}/publications/edit/${id}` + : `/dashboard/${params.entityType}/${params.entitySlug}/publications/new`; + router.push(`${base}/${resolvedPath}`); + }) + .catch(() => undefined); + }; + + const steps: StepperItem[] = [ + { + step: 1, + label: 'Files', + description: 'Upload files & videos', + icon: IconCloudUpload, + isCompleted: hasBlocks || (!completionReady && currentStep > 1), + content: currentStep === 1 ? children : null, + }, + { + step: 2, + label: 'Details', + description: 'Name, describe & classify', + icon: IconFileDescription, + isCompleted: detailsComplete || (!completionReady && currentStep > 2), + content: + currentStep === 2 ? ( + <> + + {children} + + ) : null, + }, + { + step: 3, + label: 'Review & Publish', + description: 'Check readiness', + icon: IconClipboardCheck, + isCompleted: false, + content: currentStep === 3 ? children : null, + }, + ]; + + return ( +
+ { + if (mode === 'new') { + setDetails((current) => ({ ...current, title: nextTitle })); + return; + } + saveTitle(nextTitle); + }} + /> + +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/context.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/context.tsx new file mode 100644 index 00000000..726418fa --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/context.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { createContext, useCallback, useContext, useRef, useState } from 'react'; + +import { + emptyDetails, + type DetailsDraft, + type NavigateResult, + type PendingBlock, +} from './model'; + +type SaveStatus = 'unsaved' | 'loading' | 'success'; +type BeforeNavigateHandler = (() => Promise | NavigateResult | void) | null; + +interface PublicationDraftContextValue { + details: DetailsDraft; + setDetails: (next: DetailsDraft | ((current: DetailsDraft) => DetailsDraft)) => void; + pendingBlocks: PendingBlock[]; + setPendingBlocks: ( + next: PendingBlock[] | ((current: PendingBlock[]) => PendingBlock[]) + ) => void; + status: SaveStatus; + setStatus: (status: SaveStatus) => void; + showErrors: boolean; + setShowErrors: (show: boolean) => void; + detailsReady: boolean; + setDetailsReady: (ready: boolean) => void; + registerBeforeNavigateHandler: (handler: BeforeNavigateHandler) => void; + runBeforeNavigateHandler: () => Promise; + resetDraft: () => void; +} + +const PublicationDraftContext = + createContext(null); + +export function PublicationDraftProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [details, setDetails] = useState(emptyDetails); + const [pendingBlocks, setPendingBlocks] = useState([]); + const [status, setStatus] = useState('unsaved'); + const [showErrors, setShowErrors] = useState(false); + const [detailsReady, setDetailsReady] = useState(false); + const beforeNavigateHandlerRef = useRef(null); + + const registerBeforeNavigateHandler = useCallback( + (handler: BeforeNavigateHandler) => { + beforeNavigateHandlerRef.current = handler; + }, + [] + ); + + const runBeforeNavigateHandler = useCallback(async () => { + return beforeNavigateHandlerRef.current?.(); + }, []); + + const resetDraft = useCallback(() => { + setDetails(emptyDetails()); + setPendingBlocks([]); + setStatus('unsaved'); + setShowErrors(false); + setDetailsReady(false); + }, []); + + return ( + + {children} + + ); +} + +export function usePublicationDraft() { + const context = useContext(PublicationDraftContext); + if (!context) { + throw new Error( + 'usePublicationDraft must be used within PublicationDraftProvider' + ); + } + return context; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit.module.scss new file mode 100644 index 00000000..5bed2b12 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit.module.scss @@ -0,0 +1,18 @@ +.publicationStepper { + padding-left: var(--space-6); + padding-right: var(--space-6); + padding-top: var(--space-6); + --action-primary-basic-default: var(--blue-primary-color); + --action-primary-basic-hovered: var(--blue-primary-text); + --action-primary-basic-pressed: var(--blue-primary-text); + --text-highlight: var(--blue-primary-color); + + [class*='buttonNext'] { + background-color: var(--blue-primary-color) !important; + border-color: var(--blue-primary-color) !important; + color: var(--base-pure-white) !important; + --button-color: var(--blue-primary-color); + --button-color-hover: var(--blue-primary-text); + --button-color-active: var(--blue-primary-text); + } +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/details/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/details/page.tsx new file mode 100644 index 00000000..d3f7eaeb --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/details/page.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import { DetailsStep } from '../../../components/DetailsStep'; + +export default function EditPublicationDetailsPage() { + const params = useParams<{ id: string }>(); + return ; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/files/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/files/page.tsx new file mode 100644 index 00000000..40aef101 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/files/page.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import { FilesStep } from '../../../components/FilesStep'; + +export default function EditPublicationFilesPage() { + const params = useParams<{ id: string }>(); + return ; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/layout.tsx new file mode 100644 index 00000000..aed88983 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/layout.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useEffect } from 'react'; + +import { PublicationWizard } from '../../components/WizardShell'; +import { usePublicationDraft } from '../../context'; + +export default function EditPublicationLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { pendingBlocks, setStatus } = usePublicationDraft(); + + useEffect(() => { + if (pendingBlocks.length === 0) setStatus('success'); + }, [pendingBlocks.length, setStatus]); + + return {children}; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/page.tsx new file mode 100644 index 00000000..45c90e55 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +export default function EditPublicationIndexPage() { + const router = useRouter(); + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + + useEffect(() => { + router.replace( + `/dashboard/${params.entityType}/${params.entitySlug}/publications/edit/${params.id}/files` + ); + }, [params.entitySlug, params.entityType, params.id, router]); + + return null; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/publish/page.tsx new file mode 100644 index 00000000..6602d789 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/edit/[id]/publish/page.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import { ReviewStep } from '../../../components/ReviewStep'; + +export default function EditPublicationReviewPage() { + const params = useParams<{ id: string }>(); + return ; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/layout.tsx new file mode 100644 index 00000000..72d74aec --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/layout.tsx @@ -0,0 +1,9 @@ +import { PublicationDraftProvider } from './context'; + +export default function PublicationsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/model.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/model.ts new file mode 100644 index 00000000..769d3bb4 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/model.ts @@ -0,0 +1,246 @@ +export const MAX_PUBLICATION_FILE_BYTES = 50 * 1024 * 1024; + +export const PUBLICATION_FILE_EXTENSIONS = [ + 'pdf', + 'doc', + 'docx', + 'ppt', + 'pptx', +] as const; + +export const PUBLICATION_FILE_ACCEPT = PUBLICATION_FILE_EXTENSIONS.map( + (extension) => `.${extension}` +).join(','); + +export const LICENSE_OPTIONS = [ + { + label: 'CC BY 4.0', + value: 'CC_BY_4_0_ATTRIBUTION', + }, + { + label: 'CC BY-SA 4.0', + value: 'CC_BY_SA_4_0_ATTRIBUTION_SHARE_ALIKE', + }, + { + label: 'Government Open Data License', + value: 'GOVERNMENT_OPEN_DATA_LICENSE', + }, + { + label: 'Open Data Commons (Attribution)', + value: 'OPEN_DATA_COMMONS_BY_ATTRIBUTION', + }, + { label: 'Open Database License', value: 'OPEN_DATABASE_LICENSE' }, +]; + +export type ContributorDraft = { + id: string; + name: string; + username?: string; + imageUrl?: string; +}; + +export type TagOption = { + label: string; + value: string; +}; + +export type DetailsDraft = { + title: string; + description: string; + publicationDate: string; + license: string; + resourceTypeId: string; + sectors: TagOption[]; + geographies: TagOption[]; + externalSourceLink: string; + contributors: ContributorDraft[]; +}; + +export type PendingBlock = + | { + id: string; + kind: 'file'; + file: File; + addedAt: string; + title: string; + description: string; + } + | { + id: string; + kind: 'video'; + url: string; + videoId: string; + addedAt: string; + title: string; + description: string; + }; + +export function fileDisplayTitle(name: string) { + const base = name.split(/[/\\]/).pop() ?? name; + const stem = base.includes('.') ? base.slice(0, base.lastIndexOf('.')) : base; + return stem || base; +} + +export type NavigateResult = { id: string; step?: number }; + +export function publicationWriteInput(details: DetailsDraft) { + return { + title: details.title.trim(), + description: details.description.trim(), + authors: details.contributors + .map((contributor) => contributor.name.trim()) + .filter(Boolean), + publicationDate: details.publicationDate || null, + license: details.license || null, + resourceTypeId: details.resourceTypeId || null, + sectorIds: (details.sectors ?? []).map((sector) => sector.value), + geographyIds: (details.geographies ?? []) + .map((geography) => Number.parseInt(geography.value, 10)) + .filter((id) => Number.isFinite(id)), + externalSourceLink: details.externalSourceLink.trim() || null, + }; +} + +export function emptyDetails(): DetailsDraft { + return { + title: '', + description: '', + publicationDate: '', + license: '', + resourceTypeId: '', + sectors: [], + geographies: [], + externalSourceLink: '', + contributors: [], + }; +} + +export function licenseLabel(value?: string | null) { + return LICENSE_OPTIONS.find((option) => option.value === value)?.label ?? value ?? ''; +} + +export function fileExtension(name: string) { + const extension = name.split('.').pop()?.toLowerCase() ?? ''; + return extension; +} + +export function isAllowedPublicationFile(file: File) { + return PUBLICATION_FILE_EXTENSIONS.includes( + fileExtension(file.name) as (typeof PUBLICATION_FILE_EXTENSIONS)[number] + ); +} + +export function formatFileSize(bytes?: number | null) { + if (bytes == null || Number.isNaN(bytes)) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function formatUploadedAt(value?: string | null) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString('en-GB', { hour12: false }); +} + +export function youtubeVideoId(url: string) { + try { + const parsed = new URL(url.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + const host = parsed.hostname.replace(/^www\./, ''); + if (host === 'youtu.be') { + return parsed.pathname.split('/').filter(Boolean)[0] ?? null; + } + if (host === 'youtube.com' || host === 'm.youtube.com') { + if (parsed.pathname.startsWith('/embed/')) { + return parsed.pathname.split('/')[2] ?? null; + } + if (parsed.pathname.startsWith('/shorts/')) { + return parsed.pathname.split('/')[2] ?? null; + } + return parsed.searchParams.get('v'); + } + } catch { + return null; + } + return null; +} + +export function isHttpUrl(value: string) { + try { + const parsed = new URL(value.trim()); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +export type DetailsErrors = Partial>; + +export function detailsErrors(details: DetailsDraft): DetailsErrors { + const errors: DetailsErrors = {}; + if (!details.title.trim()) errors.title = 'Enter a resource name.'; + if (!details.description.trim()) errors.description = 'Enter a description.'; + if (details.contributors.length === 0) { + errors.contributors = 'Add at least one contributor.'; + } + if (!details.publicationDate) errors.publicationDate = 'Enter a date.'; + if (!details.license) errors.license = 'Select usage rights.'; + if (!details.resourceTypeId) errors.resourceTypeId = 'Select a resource type.'; + if ((details.sectors ?? []).length === 0) { + errors.sectors = 'Select at least one sector.'; + } + if ((details.geographies ?? []).length === 0) { + errors.geographies = 'Select at least one geography.'; + } + if ( + details.externalSourceLink.trim() && + !isHttpUrl(details.externalSourceLink) + ) { + errors.externalSourceLink = 'Enter a valid URL.'; + } + return errors; +} + +export function isDetailsComplete(details: DetailsDraft) { + return Object.keys(detailsErrors(details)).length === 0; +} + +export function mutationMessage( + payload: + | { + success?: boolean | null; + errors?: { + nonFieldErrors?: Array | null; + fieldErrors?: Array<{ + messages?: Array | null; + } | null> | null; + } | null; + } + | null + | undefined, + fallback: string +) { + if (!payload || payload.success) return null; + const fieldMessage = payload.errors?.fieldErrors + ?.flatMap((item) => item?.messages ?? []) + .find((message): message is string => Boolean(message)); + return ( + payload.errors?.nonFieldErrors?.find( + (message): message is string => Boolean(message) + ) || + fieldMessage || + fallback + ); +} + +export function errorText(error: unknown, fallback: string) { + return typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() + ? error.message + : fallback; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/details/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/details/page.tsx new file mode 100644 index 00000000..9c8719e8 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/details/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { DetailsStep } from '../../components/DetailsStep'; + +export default function NewPublicationDetailsPage() { + return ; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/files/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/files/page.tsx new file mode 100644 index 00000000..8271fcac --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/files/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { FilesStep } from '../../components/FilesStep'; + +export default function NewPublicationFilesPage() { + return ; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/layout.tsx new file mode 100644 index 00000000..4176ce2b --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/layout.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import { PublicationWizard } from '../components/WizardShell'; +import { usePublicationDraft } from '../context'; + +export default function NewPublicationLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { resetDraft } = usePublicationDraft(); + const resetOnce = useRef(false); + + useEffect(() => { + if (!resetOnce.current) { + resetOnce.current = true; + resetDraft(); + } + return () => resetDraft(); + }, [resetDraft]); + + return {children}; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/page.tsx new file mode 100644 index 00000000..a8e8e47f --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/new/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +export default function NewPublicationIndexPage() { + const router = useRouter(); + const params = useParams<{ entityType: string; entitySlug: string }>(); + + useEffect(() => { + router.replace( + `/dashboard/${params.entityType}/${params.entitySlug}/publications/new/files` + ); + }, [params.entitySlug, params.entityType, router]); + + return null; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/page.tsx new file mode 100644 index 00000000..9789ce64 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/page.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { parseAsString, useQueryState } from 'nuqs'; +import { Button, DataTable, Icon, IconButton, Text, toast } from 'opub-ui'; +import { twMerge } from 'tailwind-merge'; + +import { GraphQL } from '@/lib/api'; +import { formatDate } from '@/lib/utils'; +import { Icons } from '@/components/icons'; +import { LinkButton } from '@/components/Link'; +import { Loading } from '@/components/loading'; +import { ActionBar } from '../provider-flow/ActionBar'; +import { Navigation } from '../provider-flow/Navigation'; +import { errorText, mutationMessage } from './model'; +import { + createPublicationMutation, + deletePublicationMutation, + publicationListQuery, + unpublishPublicationMutation, +} from './queries'; + +interface PublicationListItem { + id: string; + title?: string | null; + status?: string | null; + created?: string | null; + modified?: string | null; +} + +export default function PublicationsPage() { + const router = useRouter(); + const params = useParams<{ entityType?: string; entitySlug?: string }>(); + const entityType = params?.entityType; + const entitySlug = params?.entitySlug; + const isValidParams = + typeof entityType === 'string' && typeof entitySlug === 'string'; + const ownerArgs = isValidParams ? { [entityType]: entitySlug } : {}; + const [navigationTab, setNavigationTab] = useQueryState('tab', parseAsString); + + const listQuery = useQuery( + [ + 'dashboard_publications', + entityType, + entitySlug, + navigationTab ?? 'drafts', + ], + () => + GraphQL(publicationListQuery, ownerArgs, { + pagination: { limit: 100, offset: 0 }, + }), + { enabled: isValidParams } + ); + + useEffect(() => { + if (navigationTab == null) setNavigationTab('drafts'); + }, [navigationTab, setNavigationTab]); + + const deleteMutation = useMutation( + (id: string) => + GraphQL(deletePublicationMutation, ownerArgs, { publicationId: id }), + { + onSuccess: (result) => { + const message = mutationMessage( + result.deletePublication, + 'Could not delete the publication.' + ); + if (message) { + toast(message); + return; + } + toast('Publication deleted'); + void listQuery.refetch(); + }, + onError: (error: unknown) => { + toast(errorText(error, 'Could not delete the publication.')); + }, + } + ); + + const unpublishMutation = useMutation( + (id: string) => + GraphQL(unpublishPublicationMutation, ownerArgs, { publicationId: id }), + { + onSuccess: (result) => { + const message = mutationMessage( + result.unpublishPublication, + 'Could not unpublish the publication.' + ); + if (message) { + toast(message); + return; + } + toast('Publication unpublished'); + void listQuery.refetch(); + }, + onError: (error: unknown) => { + toast(errorText(error, 'Could not unpublish the publication.')); + }, + } + ); + + const createMutation = useMutation( + () => + GraphQL(createPublicationMutation, ownerArgs, { + input: {}, + }), + { + onSuccess: (result) => { + const message = mutationMessage( + result.createPublication, + 'Could not create the publication.' + ); + const id = result.createPublication?.data?.id; + if (message || !id || !entityType || !entitySlug) { + toast(message || 'Could not create the publication.'); + return; + } + toast('Publication created successfully'); + void listQuery.refetch(); + router.push( + `/dashboard/${entityType}/${entitySlug}/publications/edit/${id}/files` + ); + }, + onError: (error: unknown) => { + toast(errorText(error, 'Could not create the publication.')); + }, + } + ); + + if (!isValidParams) return null; + + const navigationOptions = [ + { label: 'Drafts', url: 'drafts', selected: navigationTab !== 'published' }, + { + label: 'Published', + url: 'published', + selected: navigationTab === 'published', + }, + ]; + const publishedTab = navigationTab === 'published'; + const rows = (listQuery.data?.publications ?? []).filter((item) => + publishedTab ? item.status === 'PUBLISHED' : item.status !== 'PUBLISHED' + ); + + const columns = [ + { + accessorKey: 'title', + header: 'Title', + cell: ({ row }: { row: { original: PublicationListItem } }) => + publishedTab ? ( + + {row.original.title} + + ) : ( + + + {row.original.title || 'Untitled Publication'} + + + ), + }, + { accessorKey: 'created', header: 'Date Created' }, + { accessorKey: 'modified', header: 'Date Modified' }, + { + accessorKey: 'delete', + header: publishedTab ? 'Unpublish' : 'Delete', + cell: ({ row }: { row: { original: PublicationListItem } }) => + publishedTab ? ( + + ) : ( + deleteMutation.mutate(row.original.id)} + > + Delete + + ), + }, + ]; + + const tableRows = rows.map((item) => ({ + title: item.title, + id: item.id, + status: item.status, + created: formatDate(item.created ?? null) || '', + modified: formatDate(item.modified ?? null) || '', + })); + + return ( +
+ + {rows.length > 0 ? ( +
+ createMutation.mutate(), + }} + /> + +
+ ) : listQuery.isLoading ? ( + + ) : ( +
+
+ + {publishedTab ? ( + + No published publications yet. + + ) : ( + <> + + You have not added any publication yet. + + + + )} +
+
+ )} +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/preview/[id]/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/preview/[id]/page.tsx new file mode 100644 index 00000000..555127e6 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/preview/[id]/page.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Button, Spinner, Text, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { + errorText, + formatFileSize, + licenseLabel, + mutationMessage, +} from '../../model'; +import { + publicationEditorQuery, + publishPublicationMutation, + unpublishPublicationMutation, +} from '../../queries'; + +function downloadUrl(blockId: string) { + return `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/publications/blocks/${blockId}/download/`; +} + +export default function PublicationPreviewPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + const headers = { [params.entityType]: params.entitySlug }; + + const previewQuery = useQuery( + ['publication_editor', params.id], + () => + GraphQL(publicationEditorQuery, headers, { publicationId: params.id }), + { refetchOnMount: 'always' } + ); + + const publish = useMutation( + () => + GraphQL(publishPublicationMutation, headers, { + publicationId: params.id, + }), + { + onSuccess: (result) => { + const message = mutationMessage( + result.publishPublication, + 'Could not publish this publication.' + ); + if (message) { + toast(message); + return; + } + toast('Publication published'); + void previewQuery.refetch(); + }, + onError: (error: unknown) => { + toast(errorText(error, 'Could not publish this publication.')); + }, + } + ); + + const unpublish = useMutation( + () => + GraphQL(unpublishPublicationMutation, headers, { + publicationId: params.id, + }), + { + onSuccess: (result) => { + const message = mutationMessage( + result.unpublishPublication, + 'Could not unpublish this publication.' + ); + if (message) { + toast(message); + return; + } + toast('Publication unpublished'); + void previewQuery.refetch(); + }, + onError: (error: unknown) => { + toast(errorText(error, 'Could not unpublish this publication.')); + }, + } + ); + + if (previewQuery.isLoading) { + return ( +
+ +
+ ); + } + + const publication = previewQuery.data?.getPublication; + if (!publication) { + return ( +
+ Publication not found. +
+ ); + } + + const published = publication.status === 'PUBLISHED'; + const blocks = [...(publication.blocks ?? [])].sort( + (a, b) => a.position - b.position + ); + + return ( +
+
+
+ + {published ? 'Published' : 'Preview'} + +
+ + {published + ? 'This publication is public.' + : 'This is how the publication will look once published.'} + +
+
+ {published ? ( + + ) : ( + + )} +
+ +
+ + {publication.title || 'Untitled Publication'} + + {publication.authors?.length ? ( + {publication.authors.join(', ')} + ) : null} + + {[ + publication.publicationDate, + licenseLabel(publication.license), + publication.resourceType?.name, + ] + .filter(Boolean) + .join(' · ')} + + {publication.description ? {publication.description} : null} + {publication.externalSourceLink ? ( + + {publication.externalSourceLink} + + ) : null} +
+ +
+ {blocks.map((block) => { + const isVideo = block.blockType.toUpperCase().includes('YOUTUBE'); + const label = + block.title?.trim() || + (isVideo ? 'YouTube video' : block.fileName || 'File'); + if (isVideo && block.youtubeVideoId) { + return ( +
+ {label} + {block.description ? {block.description} : null} +