From 7675c7fed2285b59c1c9e371b7a1ef4784676f41 Mon Sep 17 00:00:00 2001 From: Abhishekfm Date: Sat, 19 Sep 2026 14:23:51 +0530 Subject: [PATCH 1/3] chore: update dependencies and add tokens.json for design tokens - Updated opub-ui to version 0.5.6 in package.json and package-lock.json. - Added tokens.json file containing design tokens for colors and app theme. - Refactored some component styles and layout adjustments in Content.tsx and Sectors.tsx. - Enhanced dataset editing functionality with new context and components for managing dataset metadata and resources. --- app/[locale]/(user)/components/Content.tsx | 2 +- app/[locale]/(user)/components/Sectors.tsx | 6 +- .../[id]/edit/components/EditLayout.tsx | 337 +- .../[id]/edit/components/EditMetadata.tsx | 554 ++- .../[id]/edit/components/WizardHeader.tsx | 50 + .../dataset/[id]/edit/context.tsx | 32 +- .../dataset/[id]/edit/dataset-summary.ts | 59 + .../dataset/[id]/edit/edit.module.scss | 18 + .../dataset/[id]/edit/page-layout.tsx | 56 +- .../dataset/[id]/edit/publish/page.tsx | 885 ++-- .../components/PublicPlatformImport.tsx | 112 + .../resources/components/ResourceDropzone.tsx | 140 +- .../resources/components/ResourceListView.tsx | 358 +- .../components/ResourceViewSheet.tsx | 354 ++ .../[id]/edit/resources/components/query.ts | 53 + .../[id]/edit/resources/page-layout.tsx | 176 +- .../dataset/[id]/edit/resources/query.ts | 1 + .../[entitySlug]/dataset/page-layout.tsx | 2 +- .../[entitySlug]/dataset/page.tsx | 4 +- .../[entityType]/[entitySlug]/layout.tsx | 12 +- .../dashboard/components/dashboard-nav.tsx | 292 +- app/[locale]/dashboard/dashboard.module.scss | 6 +- app/api/website-title/route.ts | 29 + components/BreadCrumbs/index.tsx | 2 +- components/RichTextEditor/RichTextEditor.tsx | 18 +- components/icons.tsx | 2 + lib/utils.ts | 17 +- package-lock.json | 2017 ++++----- package.json | 2 +- styles/tokens.json | 548 ++- styles/tokens/_variables.css | 53 + styles/tokens/tailwind/color.js | 21 +- tokens.json | 3770 +++++++++++++++++ 33 files changed, 7644 insertions(+), 2344 deletions(-) create mode 100644 app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/WizardHeader.tsx create mode 100644 app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/dataset-summary.ts create mode 100644 app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PublicPlatformImport.tsx create mode 100644 app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceViewSheet.tsx create mode 100644 app/api/website-title/route.ts create mode 100644 tokens.json diff --git a/app/[locale]/(user)/components/Content.tsx b/app/[locale]/(user)/components/Content.tsx index 316d8aab..42236165 100644 --- a/app/[locale]/(user)/components/Content.tsx +++ b/app/[locale]/(user)/components/Content.tsx @@ -120,7 +120,7 @@ export const Content = () => { {item.label} diff --git a/app/[locale]/(user)/components/Sectors.tsx b/app/[locale]/(user)/components/Sectors.tsx index da3e4bcf..aab50466 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 = () => {
- - - {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,28 @@ const Page = () => { entitySlug: string; id: string; }>(); + const router = useRouter(); const getDatasetsSummary = useQuery([`summary_${params.id}`], () => - GraphQL( - datasetSummaryQuery, - { - [params.entityType]: params.entitySlug, - }, - { filters: { id: params.id } } - ) - ); - - useEffect(() => { - getDatasetsSummary.refetch(); - }); + GraphQL( + datasetSummaryQuery, + { + [params.entityType]: params.entitySlug, + }, + { filters: { id: params.id } } + ) + ); 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 +233,308 @@ 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/PublicPlatformImport.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PublicPlatformImport.tsx new file mode 100644 index 00000000..dc89a719 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PublicPlatformImport.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Form, SectionCard, Select, TextField } from 'opub-ui'; +import { useFormContext } from 'react-hook-form'; + +import { useDatasetEditStatus } from '../../context'; + +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.', + }, +]; + +const platformFormOptions = { + defaultValues: { + platform: '', + }, +}; + +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; +} + +export function PublicPlatformImport() { + const { stepShowErrors } = useDatasetEditStatus(); + const [platform, setPlatform] = useState(''); + const [datasetUrl, setDatasetUrl] = useState(''); + const [urlBlurred, setUrlBlurred] = useState(false); + + const showUrlField = platform.length > 0; + const showUrlError = + showUrlField && + datasetUrl.trim().length === 0 && + (stepShowErrors || urlBlurred); + const platformError = + stepShowErrors && !platform ? 'Select a platform.' : undefined; + + return ( + +
+ +
+ { - handleInputChange('modelType', value); - handleSave({ ...formData, modelType: value }); - }} - required - /> - { - handleInputChange('maxTokens', value); - handleSave({ ...formData, maxTokens: value }); - }} - required - requiredIndicator={true} /> +
+
+ + +
+
+ option.value !== '')} + selectedValue={formData.domain} + onChange={(value) => { + const next = comboboxSingle(value); + handleInputChange('domain', next); + handleSave({ ...formData, domain: next }); + }} + /> +
+
+ ({ + label: item.name, + value: item.id, + })) || [] + } + key={`sectors-${getSectorsList.data?.sectors?.length || 0}-${formData.sectors.length}`} + label="Sectors" + placeholder="Select sectors..." + selectedValue={formData.sectors || []} + error={sectorError} + onChange={(value) => { + const next = toSelectOptions(value); + handleInputChange('sectors', next); + handleSave({ ...formData, sectors: next }); + }} + required + requiredIndicator + /> +
({ + label: item.value, + value: item.id, + })) || [] + } + key={`tags-${getTagsList.data?.tags?.length || 0}-${formData.tags.length}`} + label="Tags" + creatable + placeholder="Type a tag and press Enter..." + selectedValue={formData.tags || []} onChange={(value) => { const next = toSelectOptions(value); - handleInputChange('supportedLanguages', next); - handleSave({ ...formData, supportedLanguages: next }); + setIsTagsListUpdated(true); + handleInputChange('tags', next); + handleSave({ ...formData, tags: next }); }} - required - requiredIndicator={true} - /> - - - - {/* Model Website & Locations/Geography - side by side */} - - - handleInputChange('modelWebsite', value)} - onBlur={handleWebsiteBlur} - placeholder="https://www.model.com" - required - requiredIndicator={true} /> +
+ { + const next = toSelectOptions(value); + handleInputChange('supportedLanguages', next); + handleSave({ ...formData, supportedLanguages: next }); + }} + required + requiredIndicator + /> +
{ const next = toSelectOptions(value); @@ -695,65 +748,41 @@ export default function AIModelDetailsPage() { handleSave({ ...formData, geographies: next }); }} /> -
-
- - {/* Usage License & Access Type - side by side */} - - - { + handleInputChange('usageLicense', value); + handleSave({ ...formData, usageLicense: value }); + }} + /> +
+ +
); } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/page.tsx index 24d728a9..94da835e 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/page.tsx @@ -1,13 +1,62 @@ -import { redirect } from 'next/navigation'; - -export default async function AIModelEditPage({ - params, -}: { - params: Promise<{ entityType: string; entitySlug: string; id: string }>; -}) { - const { entityType, entitySlug, id } = await params; - // Redirect to the details page by default - redirect( - `/dashboard/${entityType}/${entitySlug}/aimodels/edit/${id}/details` +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { firstIncompleteAIModelEditStep } from '../aimodel-summary'; +import { FetchAIModelForPublish } from './publish/page'; + +export default function AIModelEditIndexPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + + const summaryQuery = useQuery( + [ + `fetch_AIModelForPublish`, + params.id, + params.entityType, + params.entitySlug, + ], + () => + GraphQL( + FetchAIModelForPublish, + { [params.entityType]: params.entitySlug }, + { filters: { id: parseInt(params.id, 10) } } + ), + { enabled: Boolean(params.id) } + ); + + const model = summaryQuery.data?.aiModels?.[0]; + + useEffect(() => { + if (summaryQuery.isLoading) return; + const step = model ? firstIncompleteAIModelEditStep(model) : 'versions'; + const tab = searchParams.get('tab'); + const query = tab ? `?tab=${encodeURIComponent(tab)}` : ''; + router.replace( + `/dashboard/${params.entityType}/${params.entitySlug}/aimodels/edit/${params.id}/${step}${query}` + ); + }, [ + model, + summaryQuery.isLoading, + params.entitySlug, + params.entityType, + params.id, + router, + searchParams, + ]); + + return ( +
+ +
); } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx index 67e4315f..834209da 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx @@ -3,27 +3,27 @@ import { useParams, useRouter } from 'next/navigation'; import { graphql } from '@/gql'; import { AiModelStatus, UpdateAiModelInput } from '@/gql/generated/graphql'; -import { useMutation, useQuery } from '@tanstack/react-query'; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, - Button, - Icon, - Spinner, - Table, - Tag, - Text, - toast, -} from 'opub-ui'; + IconArrowRight, + IconCheck, + IconCircleCheckFilled, + IconPencil, + IconSquareRoundedCheckFilled, +} from '@tabler/icons-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { TriangleAlert } from 'lucide-react'; +import { Button, SectionCard, Spinner, Tag, Text, toast } from 'opub-ui'; import { GraphQL } from '@/lib/api'; -import { Icons } from '@/components/icons'; -import { RichTextRenderer } from '@/components/RichTextRenderer'; +import { + languageList, + metadataString, + modelInfoIssues, + plainText, +} from '../../aimodel-summary'; import { useEditStatus } from '../../context'; -const FetchAIModelForPublish = graphql(` +export const FetchAIModelForPublish = graphql(` query AIModelForPublish($filters: AIModelFilter) { aiModels(filters: $filters) { id @@ -150,6 +150,7 @@ export default function PublishPage() { }>(); const router = useRouter(); const { setStatus } = useEditStatus(); + const stepBase = `/dashboard/${params.entityType}/${params.entitySlug}/aimodels/edit/${params.id}`; const { data, isLoading, refetch } = useQuery( [ @@ -178,13 +179,15 @@ export default function PublishPage() { const model = data?.aiModels?.[0]; const versions = model?.versions || []; - const primaryVersion = versions.find((v) => v.isLatest) || versions[0]; - const hasProviders = versions.some((v) => v.providers?.length > 0); - const PUBLISH_SUCCESS_TOAST_ID = 'publish-ai-model-success'; - const PUBLISH_ERROR_TOAST_ID = 'publish-ai-model-error'; + const primaryVersion = + versions.find((version) => version.isLatest) || versions[0]; + const accessReady = (primaryVersion?.providers?.length ?? 0) > 0; + const isPublished = model?.status === 'ACTIVE' && model?.isPublic; const { mutate, isLoading: updateLoading } = useMutation( - (mutationData: Pick) => + ( + mutationData: Pick + ) => GraphQL( UpdateAIModelStatusMutation, { @@ -214,9 +217,7 @@ export default function PublishPage() { isPublished ? 'Model unpublished successfully' : 'Model published successfully', - { - id: PUBLISH_SUCCESS_TOAST_ID, - } + { id: 'publish-ai-model-success' } ); setStatus('saved'); refetch(); @@ -226,337 +227,270 @@ export default function PublishPage() { }, onError: (error: unknown) => { const errorMessage = - typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' && error.message.trim() + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() ? error.message.trim() : isPublished ? 'Unable to unpublish model right now. Please try again.' : 'Unable to publish model right now. Please try again.'; - toast( - isPublished ? `Error: ${errorMessage}` : `Error: ${errorMessage}`, - { id: PUBLISH_ERROR_TOAST_ID } - ); + toast(`Error: ${errorMessage}`, { id: 'publish-ai-model-error' }); setStatus('unsaved'); }, } ); }; - // Validation checks for each section - const metadataErrors = []; - if (!model?.description) metadataErrors.push('Description'); - if (!model?.tags?.length) metadataErrors.push('Tags'); - if (!model?.sectors?.length) metadataErrors.push('Sectors'); - if (!model?.geographies?.length) metadataErrors.push('Geographies'); - - // Check required fields from metadata - const metadata = model?.metadata || {}; - if (!metadata.targetUsers) metadataErrors.push('Target Users'); - if (!metadata.intendedUse) metadataErrors.push('Intended Use'); - if (!metadata.modelWebsite) metadataErrors.push('Model Website'); - if (!model?.maxTokens) metadataErrors.push('Maximum Tokens'); - if (!model?.supportedLanguages?.length) - metadataErrors.push('Supported Languages'); - if (!model?.modelType) metadataErrors.push('Model Type'); - - const versionErrors = []; - if (versions.length === 0) versionErrors.push('No versions created'); - if (!primaryVersion) versionErrors.push('No primary version selected'); - if (!hasProviders) versionErrors.push('No access methods configured'); - - const Summary = [ - { - name: 'Metadata', - error: - metadataErrors.length > 0 - ? `${metadataErrors.join(', ')} missing. Please add to continue.` - : '', - }, - { - name: 'Versions & Access Methods', - error: - versionErrors.length > 0 - ? `${versionErrors.join('. ')}. Please configure to continue.` - : '', - }, - ]; - - const isPublishDisabled = - metadataErrors.length > 0 || versionErrors.length > 0; + if (isLoading) { + return ( +
+ +
+ ); + } - // Table data for versions - const versionColumns = [ - { accessorKey: 'version', header: 'Version' }, - { accessorKey: 'lifecycleStage', header: 'Lifecycle Stage' }, - { accessorKey: 'providers', header: 'Access Methods' }, - { accessorKey: 'primary', header: 'Primary' }, - ]; + const infoIssues = model + ? modelInfoIssues(model).map((issue) => ({ + ...issue, + group: 'MODEL INFORMATION', + href: `${stepBase}/${issue.href}`, + })) + : []; + const issues = [...infoIssues]; + if (versions.length === 0) { + issues.push({ + id: 'versions', + group: 'VERSIONS', + message: 'Add a version.', + href: `${stepBase}/versions`, + }); + } else if (!accessReady) { + issues.push({ + id: 'access-methods', + group: 'ACCESS METHODS', + message: 'Add at least one access method to the Primary version.', + href: `${stepBase}/versions#access-methods`, + }); + } - const versionRows = versions.map((v) => ({ - version: v.version, - lifecycleStage: lifecycleLabels[v.lifecycleStage] || v.lifecycleStage, - providers: v.providers?.length - ? v.providers - .map((p) => providerLabels[p.provider] || p.provider) - .join(', ') - : 'None', - primary: v.isLatest ? 'Yes' : 'No', - })); + const ready = issues.length === 0; + const metadata = model?.metadata; + const previewHref = `/aimodels/${params.id}`; + const languageLabels = languageList(model?.supportedLanguages).join(', '); + const sectorLabels = model?.sectors?.map((sector) => sector.name).join(', '); + const tagLabels = model?.tags?.map((tag) => tag.value).join(', '); - // Primary metadata details - const PrimaryMetadata = [ - { - label: 'Model Name', - value: model?.displayName || model?.name || '', - }, - { - label: 'Model Type', - value: - (model?.modelType && modelTypeLabels[model.modelType]) || + const summaryRows = [ + ['MODEL NAME', model?.displayName || model?.name || ''], + [ + 'MODEL TYPE', + (model?.modelType && modelTypeLabels[model.modelType]) || model?.modelType || '', - }, - { - label: 'Domain', - value: model?.domain ? domainLabels[model.domain] || model.domain : '', - }, - { - label: 'Target Users', - value: metadata?.targetUsers || '', - }, - { - label: 'Intended Use', - value: metadata?.intendedUse || '', - }, - { - label: 'Model Website', - value: metadata?.modelWebsite || '', - }, - { - label: 'Maximum Tokens', - value: model?.maxTokens ? model.maxTokens.toString() : '', - }, - { - label: 'Supported Languages', - value: model?.supportedLanguages?.length - ? model.supportedLanguages.join(', ') - : '', - }, + ], + ['DESCRIPTION', plainText(model?.description)], + ['TARGET USERS', metadataString(metadata, 'targetUsers')], + ['INTENDED USE', metadataString(metadata, 'intendedUse')], + ['DOMAIN', model?.domain ? domainLabels[model.domain] || model.domain : ''], + ['SECTORS', sectorLabels || ''], + ['TAGS', tagLabels || ''], + ['LANGUAGE SUPPORT', languageLabels], + ['MODEL WEBSITE', metadataString(metadata, 'modelWebsite')], + ['USAGE LICENSE', metadataString(metadata, 'usageLicense')], ]; - const isPublished = model?.status === 'ACTIVE' && model?.isPublic; - return ( - <> -
-
- - REVIEW AI MODEL DETAILS - - : - - Please check all the model details below before publishing +
+
+ + Review & Publish + +
+ + Check your information before making this content available + publicly.
-
- {isLoading || updateLoading ? ( -
- -
- ) : ( - <> - {Summary.map((item, index) => ( - - - -
- - {item.name} - - {item.error !== '' && ( -
- - - {item.error} - -
- )} -
-
- -
- {item.name === 'Metadata' ? ( -
- {PrimaryMetadata.map( - (meta, idx) => - meta.value && ( -
- - {meta.label}: - - - {meta.value} - -
- ) - )} - - {model?.description && ( -
- - Description: - - - - - -
- )} - -
- - Sectors: - -
- {(model?.sectors?.length ?? 0) > 0 ? ( - model?.sectors?.map((s, idx: number) => ( - {s.name} - )) - ) : ( - - None - - )} -
-
- -
- - Tags: - -
- {(model?.tags?.length ?? 0) > 0 ? ( - model?.tags?.map((t, idx: number) => ( - {t.value} - )) - ) : ( - - None - - )} -
-
- -
- - Geographies: - -
- {(model?.geographies?.length ?? 0) > 0 ? ( - model?.geographies?.map( - (g, idx: number) => ( - {g.name} - ) - ) - ) : ( - - None - - )} -
-
-
- ) : ( - // Versions & Access Methods -
- {versions.length > 0 ? ( -
- ) : ( - - No versions found - - )} - - )} - - - - - ))} + - {/* Publication Status */} - {isPublished ? ( -
-
- - - Model is Published and Active - -
- - Your AI model is now publicly accessible and can be - discovered by other users. - -
- ) : ( -
-
- - - Model is not published +
+ + {ready ? 'Ready to publish' : 'Needs attention'} + +
+ {issues.length === 0 ? ( + + Required model information and a primary access method are in + place. + + ) : ( + issues.map((issue) => ( +
+
+ +
+ + {issue.group} +
+ {issue.message}
- - {!isPublishDisabled - ? 'All checklist items are complete. You can now publish your model.' - : 'Complete all required fields before publishing your model.'} -
- )} - - + +
+ )) )}
- + + + router.push( + infoIssues[0]?.href || `${stepBase}/details#model-name` + ), + }, + ]} + > +
+ {summaryRows.map(([label, value]) => ( +
+ + {label} + + {value || '—'} +
+ ))} +
+
+ + router.push(`${stepBase}/versions`), + }, + ]} + > +
+
+ + VERSIONS CONFIGURED + + + {versions.length} version{versions.length === 1 ? '' : 's'} + +
+
+ + PRIMARY VERSION + +
+ + {primaryVersion ? `Version ${primaryVersion.version}` : '—'} + + {primaryVersion ? Primary : null} +
+
+
+ + LIFECYCLE STAGE + + + {primaryVersion + ? lifecycleLabels[primaryVersion.lifecycleStage] || + primaryVersion.lifecycleStage + : '—'} + +
+
+
+ + router.push(`${stepBase}/versions#access-methods`), + }, + ]} + > + {accessReady && primaryVersion ? ( +
+ {primaryVersion.providers?.map((provider) => ( + + {providerLabels[provider.provider] || provider.provider} + + ))} +
+ ) : ( + + No access methods configured on the Primary version yet. + + )} +
+ +
+ + Open a full preview of this AI Model in a new tab, exactly as it will + appear once published. + + + + Publishing happens from inside the preview. + + +
+ +
+ +
+
); } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/versions/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/versions/page.tsx index b3356b66..bda319c9 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/versions/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/versions/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useParams } from 'next/navigation'; import { graphql } from '@/gql'; import { @@ -13,17 +13,25 @@ import { UpdateAiModelVersionInput, UpdateVersionProviderInput, } from '@/gql/generated/graphql'; +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { + IconCheck, + IconPencil, + IconPlus, + IconTestPipe, + IconTrash, + IconX, +} from '@tabler/icons-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + AlertDialog, Button, Checkbox, - DataTable, Dialog, - Divider, FormLayout, - Icon, IconButton, Select, + Sheet, Spinner, Tag, Text, @@ -33,6 +41,8 @@ import { import { GraphQL } from '@/lib/api'; import { Icons } from '@/components/icons'; +import { useEditStatus } from '../../context'; +import styles from '../../edit.module.scss'; const fetchModelVersions = graphql(` query FetchModelVersions($filters: AIModelFilter) { @@ -140,6 +150,17 @@ const updateProviderMutation = graphql(` } `); +const deleteVersionMutation = ` + mutation deleteAIModelVersion($versionId: Int!) { + deleteAiModelVersion(versionId: $versionId) { + success + } + } +` as unknown as TypedDocumentNode< + { deleteAiModelVersion: { success: boolean } }, + { versionId: number } +>; + const deleteProviderMutation = graphql(` mutation DeleteModelVersionProvider($providerId: Int!) { deleteVersionProvider(providerId: $providerId) { @@ -172,6 +193,7 @@ interface VersionProviderRow { hfTorchDtype?: string | null; hfDeviceMap?: string | null; framework?: string | null; + config?: unknown; } interface ModelVersionRow { @@ -190,6 +212,37 @@ interface ModelVersionRow { providers: VersionProviderRow[]; } +function accessMethodName(config: unknown): string { + const value = + typeof config === 'string' + ? (() => { + try { + return JSON.parse(config) as unknown; + } catch { + return null; + } + })() + : config; + if ( + value && + typeof value === 'object' && + 'name' in value && + typeof value.name === 'string' + ) { + return value.name; + } + return ''; +} + +function modelIdPlaceholder(provider: string): string { + if (provider === 'OPENAI') return 'e.g. gpt-4o-mini'; + if (provider === 'LLAMA_OLLAMA') return 'e.g. llama3'; + if (provider === 'LLAMA_TOGETHER') + return 'e.g. meta-llama/Llama-3-8b-chat-hf'; + if (provider === 'LLAMA_REPLICATE') return 'e.g. stability-ai/sdxl'; + return 'e.g. your-model-id'; +} + export default function VersionsPage() { const params = useParams<{ entityType: string; @@ -197,6 +250,7 @@ export default function VersionsPage() { id: string; }>(); const queryClient = useQueryClient(); + const { setVersionsCompleted, stepShowErrors } = useEditStatus(); const versionsQueryKey = [ `fetch_model_versions`, params.id, @@ -222,9 +276,25 @@ export default function VersionsPage() { const [isWhatsThisModalOpen, setIsWhatsThisModalOpen] = useState(false); const [isPrimaryConfirmModalOpen, setIsPrimaryConfirmModalOpen] = useState(false); - const [selectedVersion, setSelectedVersion] = useState( - null - ); + const [selectedVersion, setSelectedVersion] = + useState(null); + const [sheetVersionId, setSheetVersionId] = useState(null); + const [sheetMode, setSheetMode] = useState<'add' | 'edit' | null>(null); + const [sheetName, setSheetName] = useState(''); + const [sheetLifecycle, setSheetLifecycle] = useState('DEVELOPMENT'); + const [sheetPrimary, setSheetPrimary] = useState(false); + const [versionToDelete, setVersionToDelete] = useState<{ + id: number; + version: string; + } | null>(null); + const [accessMethodToDelete, setAccessMethodToDelete] = useState<{ + id: number; + name: string; + } | null>(null); + const [versionTestInput, setVersionTestInput] = useState(''); + const [testedProviderIds, setTestedProviderIds] = useState([]); + const hashHandled = useRef(false); + const pendingAccessRef = useRef(false); const [editingProvider, setEditingProvider] = useState(null); const [pendingPrimaryVersionId, setPendingPrimaryVersionId] = useState< @@ -239,7 +309,8 @@ export default function VersionsPage() { }); const [providerFormData, setProviderFormData] = useState({ - provider: AiModelProvider.Custom, + accessName: '', + provider: '' as AiModelProvider | '', providerModelId: '', isPrimary: false, // API Endpoint Configuration @@ -272,9 +343,13 @@ export default function VersionsPage() { const { data, isLoading, refetch } = useQuery( versionsQueryKey, () => - GraphQL(fetchModelVersions, { [params.entityType]: params.entitySlug }, { - filters: { id: parseInt(params.id) }, - }), + GraphQL( + fetchModelVersions, + { [params.entityType]: params.entitySlug }, + { + filters: { id: parseInt(params.id) }, + } + ), { enabled: !!params.id, refetchOnMount: true, @@ -296,7 +371,9 @@ export default function VersionsPage() { ), { onSuccess: async (response) => { - toast('New version created successfully!',{id: VERSIONS_ACTION_TOAST_ID}); + toast('New version created successfully!', { + id: VERSIONS_ACTION_TOAST_ID, + }); setIsNewVersionModalOpen(false); resetVersionForm(); invalidateVersionQueries(); @@ -306,18 +383,27 @@ export default function VersionsPage() { const newVersionId = response?.createAiModelVersion?.data?.id; if (newVersionId && result.data) { - const refetchedVersions = - result.data?.aiModels?.[0]?.versions || []; + const refetchedVersions = result.data?.aiModels?.[0]?.versions || []; const newVersion = refetchedVersions.find( (v) => v.id === newVersionId ); if (newVersion) { setSelectedVersion(newVersion); + setSheetMode('edit'); + setSheetVersionId(newVersion.id); + setSheetName(newVersion.version); + setSheetLifecycle(newVersion.lifecycleStage || 'DEVELOPMENT'); + setSheetPrimary( + Boolean(newVersion.isLatest) || versions.length === 0 + ); } } }, onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`,{id: VERSIONS_ACTION_TOAST_ID}); + toast( + `Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, + { id: VERSIONS_ACTION_TOAST_ID } + ); }, } ); @@ -332,7 +418,9 @@ export default function VersionsPage() { ), { onSuccess: async () => { - toast('Provider added successfully!',{id: VERSIONS_ACTION_TOAST_ID}); + toast('Provider added successfully!', { + id: VERSIONS_ACTION_TOAST_ID, + }); setIsProviderModalOpen(false); resetProviderForm(); invalidateVersionQueries(); @@ -351,7 +439,10 @@ export default function VersionsPage() { } }, onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`,{id: VERSIONS_ACTION_TOAST_ID}); + toast( + `Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, + { id: VERSIONS_ACTION_TOAST_ID } + ); }, } ); @@ -365,7 +456,9 @@ export default function VersionsPage() { ), { onSuccess: async () => { - toast('Provider updated successfully!',{id: VERSIONS_ACTION_TOAST_ID}); + toast('Provider updated successfully!', { + id: VERSIONS_ACTION_TOAST_ID, + }); setIsProviderModalOpen(false); setEditingProvider(null); resetProviderForm(); @@ -385,7 +478,10 @@ export default function VersionsPage() { } }, onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`,{id: VERSIONS_ACTION_TOAST_ID}); + toast( + `Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, + { id: VERSIONS_ACTION_TOAST_ID } + ); }, } ); @@ -399,14 +495,15 @@ export default function VersionsPage() { ), { onSuccess: async () => { - toast('Provider deleted successfully!',{id: VERSIONS_ACTION_TOAST_ID}); + toast('Provider deleted successfully!', { + id: VERSIONS_ACTION_TOAST_ID, + }); invalidateVersionQueries(); // Force refetch and update selected version after provider deletion const result = await refetch(); if (result.data && selectedVersion) { - const refetchedVersions = - result.data?.aiModels?.[0]?.versions || []; + const refetchedVersions = result.data?.aiModels?.[0]?.versions || []; const updatedVersion = refetchedVersions.find( (v) => v.id === selectedVersion.id ); @@ -416,7 +513,34 @@ export default function VersionsPage() { } }, onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`,{id: VERSIONS_ACTION_TOAST_ID}); + toast( + `Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, + { id: VERSIONS_ACTION_TOAST_ID } + ); + }, + } + ); + + const { mutate: deleteVersion } = useMutation( + (versionId: number) => + GraphQL( + deleteVersionMutation, + { [params.entityType]: params.entitySlug }, + { versionId } + ), + { + onSuccess: () => { + toast('Version removed', { id: VERSIONS_ACTION_TOAST_ID }); + setVersionToDelete(null); + closeVersionSheet(); + void refetch(); + invalidateVersionQueries(); + }, + onError: (error: unknown) => { + toast( + `Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, + { id: VERSIONS_ACTION_TOAST_ID } + ); }, } ); @@ -430,11 +554,12 @@ export default function VersionsPage() { }); }; - const resetProviderForm = () => { + const resetProviderForm = (isPrimary = false) => { setProviderFormData({ - provider: AiModelProvider.Custom, + accessName: '', + provider: '' as AiModelProvider | '', providerModelId: '', - isPrimary: false, + isPrimary, // API Endpoint Configuration apiEndpointUrl: '', apiHttpMethod: EndpointHttpMethod.Post, @@ -461,7 +586,6 @@ export default function VersionsPage() { }; const handleCreateNewVersion = () => { - // Suggest next version number let suggestedVersion = '1.0'; if (latestVersion?.version) { const parts = latestVersion.version.split('.'); @@ -471,29 +595,37 @@ export default function VersionsPage() { } } - setNewVersionData({ - version: suggestedVersion, - lifecycleStage: AiModelLifecycleStage.Development, - copyFromVersionId: latestVersion?.id || null, - isLatest: false, - }); - setIsNewVersionModalOpen(true); + setIsProviderModalOpen(false); + setEditingProvider(null); + setSheetVersionId(null); + setSelectedVersion(null); + setSheetName(suggestedVersion); + setSheetLifecycle(AiModelLifecycleStage.Development); + setSheetPrimary(versions.length === 0); + setVersionTestInput(''); + setTestedProviderIds([]); + setSheetMode('add'); }; const handleSaveNewVersion = () => { if (!newVersionData.version) { - toast('Please enter a version number', { id: VERSIONS_VALIDATION_TOAST_ID }); + toast('Please enter a version number', { + id: VERSIONS_VALIDATION_TOAST_ID, + }); return; } if (!newVersionData.lifecycleStage) { - toast('Please select a lifecycle stage', { id: VERSIONS_VALIDATION_TOAST_ID }); + toast('Please select a lifecycle stage', { + id: VERSIONS_VALIDATION_TOAST_ID, + }); return; } createVersion({ modelId: parseInt(params.id), version: newVersionData.version, - lifecycleStage: newVersionData.lifecycleStage || AiModelLifecycleStage.Development, + lifecycleStage: + newVersionData.lifecycleStage || AiModelLifecycleStage.Development, copyFromVersionId: newVersionData.copyFromVersionId, isLatest: newVersionData.isLatest, }); @@ -507,6 +639,7 @@ export default function VersionsPage() { if (provider) { setEditingProvider(provider); setProviderFormData({ + accessName: accessMethodName(provider.config), provider: (Object.values(AiModelProvider) as string[]).includes( provider.provider ) @@ -550,7 +683,7 @@ export default function VersionsPage() { }); } else { setEditingProvider(null); - resetProviderForm(); + resetProviderForm(version.providers.length === 0); } setIsProviderModalOpen(true); }; @@ -558,6 +691,23 @@ export default function VersionsPage() { const handleSaveProvider = () => { if (!selectedVersion) return; + if (!providerFormData.accessName.trim()) { + toast('Access method name is required.', { + id: VERSIONS_VALIDATION_TOAST_ID, + }); + return; + } + if (!providerFormData.provider) { + toast('Select a provider type.', { id: VERSIONS_VALIDATION_TOAST_ID }); + return; + } + if (!providerFormData.providerModelId.trim()) { + toast('Provider model ID is required.', { + id: VERSIONS_VALIDATION_TOAST_ID, + }); + return; + } + const endpointRequiredProviders = [ 'CUSTOM', 'LLAMA_OLLAMA', @@ -566,6 +716,16 @@ export default function VersionsPage() { const isEndpointRequired = endpointRequiredProviders.includes( providerFormData.provider ); + const apiKeyRequired = + providerFormData.provider === 'OPENAI' || + providerFormData.provider === 'LLAMA_TOGETHER' || + providerFormData.provider === 'LLAMA_REPLICATE'; + if (apiKeyRequired && !providerFormData.apiKey.trim()) { + toast('API key is required for the selected provider.', { + id: VERSIONS_VALIDATION_TOAST_ID, + }); + return; + } if (isEndpointRequired && !providerFormData.apiEndpointUrl?.trim()) { toast('Endpoint URL is required for the selected provider.', { @@ -585,8 +745,8 @@ export default function VersionsPage() { } } catch { toast( - 'Please enter a valid endpoint URL (e.g., https://api.example.com/v1/chat)' - ,{ id: VERSIONS_VALIDATION_TOAST_ID } + 'Please enter a valid endpoint URL (e.g., https://api.example.com/v1/chat)', + { id: VERSIONS_VALIDATION_TOAST_ID } ); return; } @@ -597,16 +757,26 @@ export default function VersionsPage() { parsedRequestTemplate = JSON.parse(providerFormData.apiRequestTemplate); } catch { toast( - 'Invalid JSON in Request Body Template. Please check the format.' - ,{ id: VERSIONS_VALIDATION_TOAST_ID } + 'Invalid JSON in Request Body Template. Please check the format.', + { id: VERSIONS_VALIDATION_TOAST_ID } ); return; } } + const previousConfig: Record = + editingProvider?.config && + typeof editingProvider.config === 'object' && + !Array.isArray(editingProvider.config) + ? (editingProvider.config as Record) + : {}; const baseData = { providerModelId: providerFormData.providerModelId, isPrimary: providerFormData.isPrimary, + config: { + ...previousConfig, + name: providerFormData.accessName.trim(), + }, // API Endpoint Configuration apiEndpointUrl: providerFormData.apiEndpointUrl || null, apiHttpMethod: providerFormData.apiHttpMethod || EndpointHttpMethod.Post, @@ -642,7 +812,7 @@ export default function VersionsPage() { } else { createProvider({ versionId: selectedVersion.id, - provider: providerFormData.provider, + provider: providerFormData.provider as AiModelProvider, ...baseData, }); } @@ -650,12 +820,10 @@ export default function VersionsPage() { const providerOptions = [ { label: 'OpenAI', value: 'OPENAI' }, - { label: 'Llama (Ollama)', value: 'LLAMA_OLLAMA' }, - { label: 'Llama (Together AI)', value: 'LLAMA_TOGETHER' }, - { label: 'Llama (Replicate)', value: 'LLAMA_REPLICATE' }, - { label: 'Llama (Custom)', value: 'LLAMA_CUSTOM' }, + { label: 'Ollama', value: 'LLAMA_OLLAMA' }, + { label: 'Together AI', value: 'LLAMA_TOGETHER' }, + { label: 'Replicate', value: 'LLAMA_REPLICATE' }, { label: 'Custom API', value: 'CUSTOM' }, - // { label: 'Huggingface', value: 'HUGGINGFACE' }, ]; const hfModelClassOptions = [ @@ -695,23 +863,25 @@ export default function VersionsPage() { ), { onSuccess: () => { - toast('Version updated successfully!',{id: VERSIONS_ACTION_TOAST_ID}); + toast('Version updated successfully!', { + id: VERSIONS_ACTION_TOAST_ID, + }); refetch(); invalidateVersionQueries(); }, onError: (error: unknown) => { - toast(`Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`,{id: VERSIONS_ACTION_TOAST_ID}); + toast( + `Error: ${typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : String(error)}`, + { id: VERSIONS_ACTION_TOAST_ID } + ); }, } ); - const handleLifecycleChange = ( - versionId: number, - lifecycleStage: string - ) => { - const stage = ( - Object.values(AiModelLifecycleStage) as string[] - ).includes(lifecycleStage) + const handleLifecycleChange = (versionId: number, lifecycleStage: string) => { + const stage = (Object.values(AiModelLifecycleStage) as string[]).includes( + lifecycleStage + ) ? (lifecycleStage as AiModelLifecycleStage) : undefined; if (!stage) return; @@ -750,10 +920,10 @@ export default function VersionsPage() { const getProviderDisplayName = (provider: string) => { const names: Record = { OPENAI: 'OpenAI', - LLAMA_OLLAMA: 'Llama (Ollama)', + LLAMA_OLLAMA: 'Ollama', LLAMA_TOGETHER: 'Together AI', LLAMA_REPLICATE: 'Replicate', - LLAMA_CUSTOM: 'Llama Custom', + LLAMA_CUSTOM: 'Custom API', CUSTOM: 'Custom API', HUGGINGFACE: 'HuggingFace', }; @@ -777,6 +947,82 @@ export default function VersionsPage() { return provider.isPrimary ? 'Primary Source' : 'Alternate Source'; }; + useEffect(() => { + setVersionsCompleted(versions.length > 0); + }, [versions.length, setVersionsCompleted]); + + const hashTarget = latestVersion?.id; + useEffect(() => { + if (isLoading || hashHandled.current) return; + const hash = window.location.hash.replace('#', ''); + if (!hash) return; + if (hash === 'access-methods' && !hashTarget) return; + hashHandled.current = true; + if (hash === 'access-methods' && hashTarget) { + const version = versions.find((item) => item.id === hashTarget); + if (version) { + setSheetMode('edit'); + setSheetVersionId(version.id); + setSheetName(version.version); + setSheetLifecycle(version.lifecycleStage || 'DEVELOPMENT'); + setSheetPrimary(Boolean(version.isLatest)); + } + } + window.setTimeout(() => { + document.getElementById(hash)?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }, 50); + }, [isLoading, hashTarget, versions]); + + const toVersionRow = ( + version: (typeof versions)[number] + ): ModelVersionRow => ({ + id: version.id, + version: version.version, + versionNotes: version.versionNotes, + status: version.status, + lifecycleStage: version.lifecycleStage, + isLatest: version.isLatest, + supportsStreaming: version.supportsStreaming, + maxTokens: version.maxTokens, + supportedLanguages: version.supportedLanguages, + createdAt: version.createdAt, + updatedAt: version.updatedAt, + publishedAt: version.publishedAt, + providers: (version.providers ?? []) as VersionProviderRow[], + }); + + useEffect(() => { + if (!pendingAccessRef.current || sheetVersionId == null) return; + const version = versions.find((item) => item.id === sheetVersionId); + if (!version) return; + pendingAccessRef.current = false; + handleOpenProviderModal(toVersionRow(version)); + }, [sheetVersionId, versions]); + + const openVersionSheet = (version: (typeof versions)[number]) => { + setIsProviderModalOpen(false); + setEditingProvider(null); + setSelectedVersion(toVersionRow(version)); + setSheetMode('edit'); + setSheetVersionId(version.id); + setSheetName(version.version); + setSheetLifecycle(version.lifecycleStage || 'DEVELOPMENT'); + setSheetPrimary(Boolean(version.isLatest) || versions.length === 1); + setVersionTestInput(''); + setTestedProviderIds([]); + }; + + const closeVersionSheet = () => { + setSheetMode(null); + setSheetVersionId(null); + setIsProviderModalOpen(false); + setEditingProvider(null); + pendingAccessRef.current = false; + }; + if (isLoading) { return (
@@ -785,188 +1031,928 @@ export default function VersionsPage() { ); } + const sheetSource = versions.find((version) => version.id === sheetVersionId); + const sheetVersion = sheetSource ? toVersionRow(sheetSource) : null; + const onlyVersion = versions.length === 1; + + const saveSheet = () => { + const name = sheetName.trim(); + if (!name) { + toast('Please enter a version number', { + id: VERSIONS_VALIDATION_TOAST_ID, + }); + return; + } + const stage = (Object.values(AiModelLifecycleStage) as string[]).includes( + sheetLifecycle + ) + ? (sheetLifecycle as AiModelLifecycleStage) + : AiModelLifecycleStage.Development; + + if (sheetMode === 'add' || !sheetVersion) { + pendingAccessRef.current = false; + createVersion({ + modelId: parseInt(params.id), + version: name, + lifecycleStage: stage, + copyFromVersionId: null, + isLatest: versions.length === 0 || sheetPrimary, + }); + return; + } + + updateVersion({ + id: sheetVersion.id, + version: name, + lifecycleStage: stage, + isLatest: onlyVersion || sheetPrimary, + }); + closeVersionSheet(); + }; + + const beginAddAccessMethod = () => { + if (sheetVersion) { + handleOpenProviderModal(sheetVersion); + return; + } + const name = sheetName.trim(); + if (!name) { + toast('Enter a version name before adding an access method.', { + id: VERSIONS_VALIDATION_TOAST_ID, + }); + return; + } + const stage = (Object.values(AiModelLifecycleStage) as string[]).includes( + sheetLifecycle + ) + ? (sheetLifecycle as AiModelLifecycleStage) + : AiModelLifecycleStage.Development; + pendingAccessRef.current = true; + createVersion({ + modelId: parseInt(params.id), + version: name, + lifecycleStage: stage, + copyFromVersionId: null, + isLatest: versions.length === 0 || sheetPrimary, + }); + }; + + const otherPrimary = versions.find( + (version) => version.isLatest && version.id !== sheetVersion?.id + ); + const soleVersion = + sheetMode === 'add' ? versions.length === 0 : versions.length <= 1; + const sheetProviders = sheetVersion?.providers ?? []; + + const accessReady = (provider: VersionProviderRow) => { + const modelId = provider.providerModelId?.trim(); + const endpoint = provider.apiEndpointUrl?.trim(); + const key = provider.apiKey?.trim() || provider.hfAuthToken?.trim(); + if (provider.provider === 'LLAMA_OLLAMA') + return Boolean(modelId && endpoint); + if ( + provider.provider === 'CUSTOM' || + provider.provider === 'LLAMA_CUSTOM' + ) { + return Boolean(endpoint); + } + if (provider.provider === 'HUGGINGFACE') return Boolean(modelId); + return Boolean(modelId && key); + }; + + const runAccessCheck = (providerIds: number[]) => { + setTestedProviderIds((current) => + Array.from(new Set([...current, ...providerIds])) + ); + }; + return ( -
-
-
- {versions.length > 0 && ( - o.value !== '')} - value={currentVersion.lifecycleStage || 'DEVELOPMENT'} - onChange={(value) => - handleLifecycleChange(currentVersion.id, value) - } - /> +
+ {versions.map((version) => { + const providerCount = version.providers?.length ?? 0; + const stageLabel = + lifecycleStageOptions.find( + (option) => option.value === version.lifecycleStage + )?.label || version.lifecycleStage; + const isPrimary = version.isLatest || versions.length === 1; + return ( +
+
+ Version {version.version} +
+ + {stageLabel} + + {isPrimary ? ( + + + Primary + + ) : null} + + · {providerCount} configured + +
+
+
+ openVersionSheet(version)} + > + Edit Version {version.version} + + + setVersionToDelete({ + id: version.id, + version: version.version, + }) + } + > + Remove Version {version.version} + +
+ ); + })} +
+ ) : ( +
+ + No versions yet + + + Create the first version of this model. + +
+ )} -
- - handleSetPrimaryVersion( - currentVersion.id, - !currentVersion.isLatest - ) - } - > - Select as Primary Version - - setIsWhatsThisModalOpen(true)} - className="text-sm cursor-pointer text-secondaryOrange underline" +
+ +
+ + { + if (!next) { + const menuOpen = Array.from( + document.querySelectorAll( + '[class*="Select-module_Popover__"], [class*="Combobox-module_Popover__"]' + ) + ).some((node) => node instanceof HTMLElement && !node.hidden); + if (menuOpen) return; + closeVersionSheet(); + } + }} + > + +
+
+
+ - What's this? - + {sheetMode === 'add' ? 'Add Version' : 'Edit Version'} + +
+ + {sheetMode === 'add' + ? 'Define this release and how it can be accessed.' + : 'Update this release and how it can be accessed.'} + +
+
+ + Close + +
+
+
+ Version Details +
+ + + setProviderFormData((prev) => ({ + ...prev, + provider: ( + Object.values(AiModelProvider) as string[] + ).includes(value) + ? (value as AiModelProvider) + : prev.provider, + providerModelId: '', + apiKey: '', + apiEndpointUrl: '', + apiAuthHeaderName: 'Authorization', + apiRequestTemplate: '', + apiResponsePath: '', + hfAuthToken: '', + hfModelClass: '', + })) + } + disabled={!!editingProvider} + /> + {providerFormData.provider ? ( + + setProviderFormData((prev) => ({ + ...prev, + providerModelId: value, + })) + } + /> + ) : null} + + {/* OpenAI-specific fields */} + {providerFormData.provider === 'OPENAI' && ( + <> + + setProviderFormData((prev) => ({ + ...prev, + apiKey: value, + })) + } + required + requiredIndicator={true} + /> + + )} + + {/* Llama variants - Together AI, Replicate */} + {(providerFormData.provider === 'LLAMA_TOGETHER' || + providerFormData.provider === 'LLAMA_REPLICATE') && ( + <> + + setProviderFormData((prev) => ({ + ...prev, + apiKey: value, + })) + } + required + requiredIndicator={true} + /> + + )} + + {/* Llama Ollama - needs endpoint URL */} + {providerFormData.provider === 'LLAMA_OLLAMA' && ( + <> + + setProviderFormData((prev) => ({ + ...prev, + apiEndpointUrl: value, + })) + } + placeholder="http://localhost:11434/api/generate" + helpText="No API key is needed for a standard Ollama deployment." + required + requiredIndicator={true} + /> + + )} + + {/* Llama Custom - needs endpoint URL and API key */} + {providerFormData.provider === 'LLAMA_CUSTOM' && ( + <> + + setProviderFormData((prev) => ({ + ...prev, + apiEndpointUrl: value, + })) + } + placeholder="https://your-api.com/v1/chat/completions" + helpText="Full endpoint URL for your custom Llama API" + required + requiredIndicator={true} + /> + + setProviderFormData((prev) => ({ + ...prev, + apiKey: value, + })) + } + helpText="API key for authentication (if required)" + /> + + )} + + {/* Custom API - full configuration */} + {providerFormData.provider === 'CUSTOM' && ( + <> + + setProviderFormData((prev) => ({ + ...prev, + apiEndpointUrl: value, + })) + } + placeholder="https://your-api.com/v1/completions" + helpText="Full endpoint URL for your custom API" + required + requiredIndicator={true} + /> + + setProviderFormData((prev) => ({ + ...prev, + apiKey: value, + })) + } + helpText="API key or token for authentication" + /> + + setProviderFormData((prev) => ({ + ...prev, + hfModelClass: value, + })) + } + required + requiredIndicator={true} + /> + - setProviderFormData((prev) => ({ - ...prev, - provider: ( - Object.values(AiModelProvider) as string[] - ).includes(value) - ? (value as AiModelProvider) - : prev.provider, - })) - } - disabled={!!editingProvider} - /> - - setProviderFormData((prev) => ({ - ...prev, - providerModelId: value, - })) - } - helpText="e.g., gpt-4, meta-llama/Llama-2-7b-chat-hf" - /> - - {/* OpenAI-specific fields */} - {providerFormData.provider === 'OPENAI' && ( - <> - - setProviderFormData((prev) => ({ - ...prev, - apiKey: value, - })) - } - helpText="Your OpenAI API key" - required - requiredIndicator={true} - /> - - )} - - {/* Llama variants - Together AI, Replicate */} - {(providerFormData.provider === 'LLAMA_TOGETHER' || - providerFormData.provider === 'LLAMA_REPLICATE') && ( - <> - - setProviderFormData((prev) => ({ - ...prev, - apiKey: value, - })) - } - helpText={`Your ${providerFormData.provider === 'LLAMA_TOGETHER' ? 'Together AI' : 'Replicate'} API key`} - required - requiredIndicator={true} - /> - - )} - - {/* Llama Ollama - needs endpoint URL */} - {providerFormData.provider === 'LLAMA_OLLAMA' && ( - <> - - setProviderFormData((prev) => ({ - ...prev, - apiEndpointUrl: value, - })) - } - placeholder="http://localhost:11434/api/generate" - helpText="URL where Ollama is running" - required - requiredIndicator={true} - /> - - )} - - {/* Llama Custom - needs endpoint URL and API key */} - {providerFormData.provider === 'LLAMA_CUSTOM' && ( - <> - - setProviderFormData((prev) => ({ - ...prev, - apiEndpointUrl: value, - })) - } - placeholder="https://your-api.com/v1/chat/completions" - helpText="Full endpoint URL for your custom Llama API" - required - requiredIndicator={true} - /> - - setProviderFormData((prev) => ({ - ...prev, - apiKey: value, - })) - } - helpText="API key for authentication (if required)" - /> - - )} - - {/* Custom API - full configuration */} - {providerFormData.provider === 'CUSTOM' && ( - <> - - setProviderFormData((prev) => ({ - ...prev, - apiEndpointUrl: value, - })) - } - placeholder="https://your-api.com/v1/completions" - helpText="Full endpoint URL for your custom API" - required - requiredIndicator={true} - /> - - setProviderFormData((prev) => ({ - ...prev, - apiKey: value, - })) - } - helpText="API key or token for authentication" - /> - - setProviderFormData((prev) => ({ - ...prev, - hfModelClass: value, - })) - } - required - requiredIndicator={true} - /> - { + const file = event.target.files?.[0]; + if (file) onSelect(file); + event.target.value = ''; + }} + /> + {preview ? ( +
+
+ +
+ + {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); + formRef.current = form; + 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 = next.slug.trim() || slugify(next.title); + await saveCollaborative({ + data: { + id: params.id, + title: next.title, + summary: next.summary, + platformUrl: next.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 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()} + /> +
+
+ + 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..89b8ff3a --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/content/page.tsx @@ -0,0 +1,440 @@ +'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 { 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: 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/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..52c85d30 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,9 @@ export const FetchUsers = graphql(` id fullName username + profilePicture { + url + } } } `); 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..12cedafc --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/[id]/people/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { SectionCard, Spinner, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { + EntityRowPicker, + type EntityRow, +} from '../../../../usecases/edit/components/EntityRowPicker'; +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'; + +type Relationship = 'contributor' | 'partner' | 'supporter'; + +const RELATIONSHIPS: Array<{ label: string; value: Relationship }> = [ + { label: 'Contributor', value: 'contributor' }, + { label: 'Partner', value: 'partner' }, + { label: 'Supporter', value: 'supporter' }, +]; + +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/', '')}`; +} + +function rawId(id: string) { + return id.replace(/^(user|org):/, ''); +} + +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 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 + ? 'loading' + : 'success' + ); + }, [ + addingContributor, + removingContributor, + addingPartner, + removingPartner, + addingSupporter, + removingSupporter, + 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: item.username, + 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: item.username, + 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 = rawId(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 id = rawId(item.id); + if (item.kind === 'person') { + toast('People are added as contributors.'); + return; + } + if (role === 'contributor') { + toast('Organisations can be partners or supporters.'); + return; + } + 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} + roleOptionsFor={() => RELATIONSHIPS} + onRoleChange={changeRole} + onAdd={(item) => { + const id = rawId(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); + }} + /> + +
+ ); +} 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..0f9fae76 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/components/WizardHeader.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useEffect, 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); + + useEffect(() => { + setDraftTitle(title); + }, [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..daf7c406 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/edit/edit.module.scss @@ -0,0 +1,64 @@ +.collaborativeStepper { + padding-left: var(--space-6); + padding-right: var(--space-6); + padding-top: var(--space-6); + --action-primary-basic-default: var(--blue-primary-color); + --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 2c9bab54..cacef06f 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx @@ -148,7 +148,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(); } @@ -211,7 +211,7 @@ export default function CollaborativePage() { {row.original.title} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditLayout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditLayout.tsx index 9cb23f3a..ba048b7f 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditLayout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditLayout.tsx @@ -88,6 +88,7 @@ export function EditLayout({ children, params }: LayoutProps) { const pathItem = layoutList.find((item) => pathName.indexOf(item) >= 0); const currentStep = pathItem ? STEP_BY_PATH[pathItem] : 1; const dataset = getDatasetTitleRes.data?.datasets[0]; + const completionReady = getDatasetTitleRes.isFetched; const isPromptDataset = dataset?.datasetType === 'PROMPT'; const { @@ -132,7 +133,7 @@ export function EditLayout({ children, params }: LayoutProps) { ? 'Upload prompt files' : 'Upload dataset files', icon: IconCloudUpload, - isCompleted: filesCompleted, + isCompleted: filesCompleted || (!completionReady && currentStep > 1), content: stepContent(currentStep === 1, children), }, { @@ -140,7 +141,8 @@ export function EditLayout({ children, params }: LayoutProps) { label: 'Metadata', description: 'Name, description & settings', icon: IconFileDescription, - isCompleted: metadataCompleted, + isCompleted: + metadataCompleted || (!completionReady && currentStep > 2), content: stepContent(currentStep === 2, children), }, { @@ -155,13 +157,13 @@ export function EditLayout({ children, params }: LayoutProps) { return (
- {getDatasetTitleRes.isLoading ? null : ( - - )} + { + if (errors.taskType) { + setError('taskType', { type: 'manual', message: errors.taskType }); + } else { + clearErrors('taskType'); + } + }, [errors.taskType, setError, clearErrors]); + + useEffect(() => { + if (errors.domain) { + setError('domain', { type: 'manual', message: errors.domain }); + } else { + clearErrors('domain'); + } + }, [errors.domain, setError, clearErrors]); + + useEffect(() => { + if (errors.targetLanguages) { + setError('targetLanguages', { + type: 'manual', + message: errors.targetLanguages, + }); + } else { + clearErrors('targetLanguages'); + } + }, [errors.targetLanguages, setError, clearErrors]); + + useEffect(() => { + if (errors.targetModelTypes) { + setError('targetModelTypes', { + type: 'manual', + message: errors.targetModelTypes, + }); + } else { + clearErrors('targetModelTypes'); + } + }, [errors.targetModelTypes, setError, clearErrors]); + return null; } @@ -925,13 +976,23 @@ export function EditMetadata({ id }: { id: string }) { 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) + Boolean(formData.license) && + promptMetadataComplete ); }, [ formData.title, @@ -939,6 +1000,7 @@ export function EditMetadata({ id }: { id: string }) { formData.sectors, formData.tags, formData.license, + promptMetadataComplete, setMetadataCompleted, ]); @@ -993,6 +1055,31 @@ export function EditMetadata({ id }: { id: string }) { 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 ( <> {metadataReady ? ( @@ -1003,11 +1090,18 @@ export function EditMetadata({ id }: { id: string }) { sectors: sectorsError, tags: tagsError, license: licenseError, + taskType: taskTypeError, + domain: domainError, + targetLanguages: targetLanguagesError, + targetModelTypes: targetModelTypesError, }} />
- +
- +
({ label: name .replace(/_/g, ' ') @@ -1221,7 +1321,10 @@ export function EditMetadata({ id }: { id: string }) { ({ label: name .replace(/_/g, ' ') @@ -1252,7 +1355,10 @@ export function EditMetadata({ id }: { id: string }) { ({ label: name @@ -1280,8 +1386,11 @@ export function EditMetadata({ id }: { id: string }) { ({ label: name .replace(/_/g, ' ') 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 94e82e30..ae717092 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/context.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/context.tsx @@ -1,4 +1,5 @@ 'use client'; + import { createContext, useContext, useRef, useState } from 'react'; type StatusType = 'loading' | 'success'; 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 index dc89a719..cda3df2d 100644 --- 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 @@ -48,12 +48,27 @@ function SyncPlatformError({ error }: { error?: string }) { return null; } -export function PublicPlatformImport() { +export interface PlatformImportDraft { + platform: string; + datasetUrl: string; +} + +interface PublicPlatformImportProps { + onDraftChange?: (draft: PlatformImportDraft) => void; +} + +export function PublicPlatformImport({ + onDraftChange, +}: PublicPlatformImportProps) { const { stepShowErrors } = useDatasetEditStatus(); const [platform, setPlatform] = useState(''); const [datasetUrl, setDatasetUrl] = useState(''); const [urlBlurred, setUrlBlurred] = useState(false); + useEffect(() => { + onDraftChange?.({ platform, datasetUrl }); + }, [platform, datasetUrl, onDraftChange]); + const showUrlField = platform.length > 0; const showUrlError = showUrlField && diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx index 1a20c494..a620fabd 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx @@ -9,7 +9,7 @@ import { Button, DropZone, Icon, Labelled, Tag, Text, toast } from 'opub-ui'; import { GraphQL } from '@/lib/api'; import { cn } from '@/lib/utils'; import { Icons } from '@/components/icons'; -import { createResourceFilesDoc } from './query'; +import { createResourceFilesDoc, updateResourceList } from './query'; export const RESOURCE_FILE_TYPES = [ 'CSV', @@ -28,12 +28,15 @@ interface ResourceDropzoneProps { reload: () => void | Promise; onPendingChange?: (files: File[]) => void; error?: string; + /** When set, an in-flight upload is deleted instead of kept. */ + discardUploadRef?: React.MutableRefObject; } export const ResourceDropzone = ({ reload, onPendingChange, error, + discardUploadRef, }: ResourceDropzoneProps) => { const RESOURCE_UPLOAD_ERROR_TOAST_ID = 'dataset-resource-upload-error'; const getErrorMessage = (err: unknown, fallback: string) => @@ -61,7 +64,29 @@ export const ResourceDropzone = ({ data ), { - onSuccess: async () => { + onSuccess: async (result) => { + if (discardUploadRef?.current) { + const created = result.createFileResources ?? []; + await Promise.all( + created.map((item) => { + const resourceId = item?.id; + if (!resourceId) return Promise.resolve(); + return GraphQL( + updateResourceList, + { + [params.entityType]: params.entitySlug, + }, + { resourceId } + ); + }) + ); + onPendingChange?.([]); + await reload(); + void queryClient.invalidateQueries({ + queryKey: [`dataset_title_${params.id}`], + }); + return; + } await reload(); onPendingChange?.([]); void queryClient.invalidateQueries({ @@ -80,6 +105,7 @@ export const ResourceDropzone = ({ const handleDropZoneDrop = React.useCallback( (_dropFiles: File[], acceptedFiles: File[]) => { if (acceptedFiles.length === 0) return; + if (discardUploadRef) discardUploadRef.current = false; onPendingChange?.(acceptedFiles); mutate({ fileResourceInput: { @@ -88,7 +114,7 @@ export const ResourceDropzone = ({ }, }); }, - [mutate, onPendingChange, params.id] + [discardUploadRef, mutate, onPendingChange, params.id] ); return ( diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/page-layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/page-layout.tsx index a9f8f838..ae46bf5c 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/page-layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/page-layout.tsx @@ -1,13 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { IconFileSpreadsheet, IconWorld } from '@tabler/icons-react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { parseAsString, useQueryState } from 'nuqs'; import { - IconFileSpreadsheet, - IconWorld, -} from '@tabler/icons-react'; -import { + AlertDialog, SectionCard, Spinner, Tab, @@ -15,16 +13,67 @@ import { TabPanel, Tabs, Text, + toast, } from 'opub-ui'; import { GraphQL } from '@/lib/api'; import { useDatasetEditStatus } from '../context'; -import { PublicPlatformImport } from './components/PublicPlatformImport'; +import { + PublicPlatformImport, + type PlatformImportDraft, +} from './components/PublicPlatformImport'; +import { updateResourceList } from './components/query'; import { ResourceDropzone } from './components/ResourceDropzone'; import { ResourceListView } from './components/ResourceListView'; import { ResourceViewSheet } from './components/ResourceViewSheet'; import { getResourceDoc } from './query'; +type UploadTab = 'upload' | 'platform'; + +const EMPTY_PLATFORM_DRAFT: PlatformImportDraft = { + platform: '', + datasetUrl: '', +}; + +function isUploadTab(value: string): value is UploadTab { + return value === 'upload' || value === 'platform'; +} + +function keepDialogOpen(event: unknown) { + if ( + typeof event === 'object' && + event !== null && + 'preventDefault' in event && + typeof event.preventDefault === 'function' + ) { + event.preventDefault(); + } +} + +function switchDialogCopy(target: UploadTab, draft: PlatformImportDraft) { + if (target === 'platform') { + return { + description: + 'You have already uploaded files manually. Clear these files before importing files from the public platform.', + confirmLabel: 'Clear Files and Switch', + }; + } + + if (draft.datasetUrl.trim().length > 0) { + return { + description: + 'You have already entered a public platform URL. Clear it before uploading files manually.', + confirmLabel: 'Clear URL and Switch', + }; + } + + return { + description: + 'You have already selected a public platform. Clear this selection before uploading files manually.', + confirmLabel: 'Clear and Switch', + }; +} + export interface TListItem { label: string; value: string; @@ -50,8 +99,16 @@ export function DistibutionPage({ params: { entityType: string; entitySlug: string; id: string }; }) { const { setFilesCompleted, stepShowErrors } = useDatasetEditStatus(); + const queryClient = useQueryClient(); const [pendingFiles, setPendingFiles] = useState([]); - const [sourceTab, setSourceTab] = useState('upload'); + const [sourceTab, setSourceTab] = useState('upload'); + const [platformDraft, setPlatformDraft] = + useState(EMPTY_PLATFORM_DRAFT); + const [platformResetKey, setPlatformResetKey] = useState(0); + const [switchTarget, setSwitchTarget] = useState(null); + const [isClearing, setIsClearing] = useState(false); + const clearingRef = useRef(false); + const discardUploadRef = useRef(false); const { data, isLoading, refetch } = useQuery( [`fetch_resources_${params.id}`], @@ -75,6 +132,85 @@ export function DistibutionPage({ setFilesCompleted(resources.length > 0); }, [resources.length, setFilesCompleted]); + const handlePlatformDraftChange = useCallback( + (draft: PlatformImportDraft) => { + setPlatformDraft(draft); + }, + [] + ); + + const hasFiles = resources.length > 0 || pendingFiles.length > 0; + const platformOccupied = + platformDraft.platform.length > 0 || + platformDraft.datasetUrl.trim().length > 0; + + const handleTabChange = (next: string) => { + if (!isUploadTab(next) || next === sourceTab || clearingRef.current) return; + + const leavingFiles = sourceTab === 'upload' && hasFiles; + const leavingPlatform = sourceTab === 'platform' && platformOccupied; + if (leavingFiles || leavingPlatform) { + setSwitchTarget(next); + return; + } + + setSourceTab(next); + }; + + const confirmSwitch = async () => { + if (!switchTarget || clearingRef.current) return; + clearingRef.current = true; + setIsClearing(true); + + try { + if (switchTarget === 'platform') { + discardUploadRef.current = true; + const resourceIds = resources + .map((resource) => resource.id) + .filter((id): id is string => Boolean(id)); + const results = await Promise.all( + resourceIds.map(async (resourceId) => { + try { + await GraphQL( + updateResourceList, + { [params.entityType]: params.entitySlug }, + { resourceId } + ); + return true; + } catch { + return false; + } + }) + ); + setPendingFiles([]); + await refetch(); + void queryClient.invalidateQueries({ + queryKey: [`dataset_title_${params.id}`], + }); + if (results.some((cleared) => !cleared)) { + discardUploadRef.current = false; + toast('Unable to clear files right now.', { + id: 'dataset-upload-method-clear-error', + }); + return; + } + } else { + setPlatformResetKey((key) => key + 1); + setPlatformDraft(EMPTY_PLATFORM_DRAFT); + } + + setSourceTab(switchTarget); + setSwitchTarget(null); + } finally { + clearingRef.current = false; + setIsClearing(false); + } + }; + + const dialogCopy = switchTarget + ? switchDialogCopy(switchTarget, platformDraft) + : null; + if (isLoading) { return (
@@ -101,7 +237,7 @@ export function DistibutionPage({ return (
- + File Upload @@ -110,13 +246,18 @@ export function DistibutionPage({ Public Platform - +
{readyCount > 0 || pendingFiles.length > 0 ? ( @@ -139,7 +280,10 @@ export function DistibutionPage({
- + {readyCount > 0 || pendingFiles.length > 0 ? ( + { + if (!open && !clearingRef.current) setSwitchTarget(null); + }} + > + { + keepDialogOpen(event); + void confirmSwitch(); + }, + }} + secondaryActions={[ + { + content: 'Cancel', + disabled: isClearing, + }, + ]} + > + {dialogCopy?.description ?? ''} + + { diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page-layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page-layout.tsx index 04ea1a1d..cbec0c85 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page-layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page-layout.tsx @@ -9,7 +9,7 @@ import { GraphQL } from '@/lib/api'; import { ActionBar } from './components/action-bar'; import { Content } from './components/content'; -const createDatasetMutationDoc = graphql(` +export const createDatasetMutationDoc = graphql(` mutation GenerateDatasetname { addDataset { success diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/RedirectToUseCaseStep.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/RedirectToUseCaseStep.tsx new file mode 100644 index 00000000..ba190bd6 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/RedirectToUseCaseStep.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Spinner } from 'opub-ui'; + +export function RedirectToUseCaseStep({ + step, +}: { + step: 'builder' | 'connect' | 'publish'; +}) { + const router = useRouter(); + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + + useEffect(() => { + router.replace( + `/dashboard/${params.entityType}/${params.entitySlug}/usecases/edit/${params.id}/${step}` + ); + }, [params.entitySlug, params.entityType, params.id, router, step]); + + return ( +
+ +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/assign/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/assign/page.tsx index 24af3284..6f2ce546 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/assign/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/assign/page.tsx @@ -1,223 +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 { RedirectToUseCaseStep } from '../../RedirectToUseCaseStep'; -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 ; } - -const FetchUseCaseDetails = graphql(` - query UseCaseDetails($filters: UseCaseFilter) { - useCases(filters: $filters) { - id - title - datasets { - id - title - modified - sectors { - name - } - } - } - } -`); - -const AssignUsecaseDatasets = graphql(` - mutation assignDatasets($useCaseId: String!, $datasetIds: [UUID!]!) { - updateUsecaseDatasets(useCaseId: $useCaseId, datasetIds: $datasetIds) { - ... on TypeUseCase { - id - datasets { - id - title - } - } - } - } -`); -const Assign = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const USECASE_ASSIGN_SUCCESS_TOAST_ID = 'usecase-assign-datasets-success'; - const USECASE_ASSIGN_ERROR_TOAST_ID = 'usecase-assign-datasets-error'; - const getErrorMessage = (error: unknown, fallback: string) => - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message.trim() - ? error.message.trim() - : fallback; - const router = useRouter(); - const queryClient = useQueryClient(); - - const [data, setData] = useState([]); - const [selectedRow, setSelectedRows] = useState([]); - - const UseCaseDetails = - useQuery( - [`UseCase_Details`, params.id], - () => - GraphQL( - FetchUseCaseDetails, - { - [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( - AssignUsecaseDatasets, - { - [params.entityType]: params.entitySlug, - }, - { - useCaseId: params.id, - datasetIds: Array.isArray(selectedRow) - ? selectedRow.map((row) => row.id) - : [], - } - ), - { - onSuccess: () => { - toast('Dataset Assigned Successfully', { - id: USECASE_ASSIGN_SUCCESS_TOAST_ID, - }); - UseCaseDetails.refetch(); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_UsecaseDetails`, - params.id, - params.entityType, - params.entitySlug, - ], - }); - router.push( - `/dashboard/${params.entityType}/${params.entitySlug}/usecases/edit/${params.id}/dashboards` - ); - }, - onError: (err: unknown) => { - toast( - `Error: ${getErrorMessage(err, 'Unable to assign datasets right now. Please try again.')}`, - { id: USECASE_ASSIGN_ERROR_TOAST_ID } - ); - }, - } - ); - - return ( - <> - {((UseCaseDetails?.data?.useCases?.[0]?.datasets?.length ?? -1) >= 0) && - data.length > 0 && - !UseCaseDetails.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]/usecases/edit/[id]/builder/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/builder/page.tsx new file mode 100644 index 00000000..d8cf230e --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/builder/page.tsx @@ -0,0 +1,583 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import { useParams } from 'next/navigation'; +import { graphql } from '@/gql'; +import { UseCaseInputPartial } from '@/gql/generated/graphql'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Button, + DropZone, + SectionCard, + Spinner, + Text, + TextField, + toast, +} from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { ContentEditor } from '../../components/ContentEditor'; +import { + extractEmbedUrl, + parseUseCaseContent, + serializeUseCaseContent, + type UseCaseContentDocument, +} from '../../content-document'; +import { useUseCaseEditStatus } from '../../context'; +import styles from '../../edit.module.scss'; +import { isUseCaseBuilderComplete } from '../../usecase-summary'; + +interface UploadedImage { + name?: string | null; + path?: string | null; + url?: string | null; +} + +const FetchUseCaseBuilder = graphql(` + query UseCaseBuilderData($filters: UseCaseFilter) { + useCases(filters: $filters) { + id + title + summary + logo { + name + path + url + } + } + } +`); + +const UpdateUseCaseBuilder = graphql(` + mutation updateUseCaseBuilder($data: UseCaseInputPartial!) { + updateUseCase(data: $data) { + __typename + id + title + summary + logo { + name + path + url + } + } + } +`); + +const dashboardListQuery = graphql(` + query useCaseBuilderDashboards($usecaseId: Int!) { + usecaseDashboards(usecaseId: $usecaseId) { + id + name + link + } + } +`); + +const addDashboardMutation = graphql(` + mutation addUseCaseBuilderDashboard( + $usecaseId: Int! + $name: String + $link: String + ) { + addUsecaseDashboard(usecaseId: $usecaseId, name: $name, link: $link) { + success + data { + id + name + link + } + } + } +`); + +const updateDashboardMutation = graphql(` + mutation updateUseCaseBuilderDashboard( + $id: String! + $name: String + $link: String + ) { + updateUsecaseDashboard(id: $id, name: $name, link: $link) { + success + data { + id + name + link + } + } + } +`); + +const chartListQuery = graphql(` + query UseCaseWizardCharts { + getChartData { + __typename + ... on TypeResourceChart { + name + id + } + ... on TypeResourceChartImage { + name + id + } + } + } +`); + +const MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024; + +function mediaUrl(file?: UploadedImage | null) { + const raw = file?.url || file?.path; + if (!raw) return ''; + if (raw.startsWith('http')) return raw; + return `${process.env.NEXT_PUBLIC_BACKEND_URL}/${raw.replace('/code/files/', '')}`; +} + +function formatFileSize(bytes: number) { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; + const megabytes = bytes / (1024 * 1024); + return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; +} + +function thumbnailName(file: File | UploadedImage) { + if (file instanceof File) return file.name; + const raw = file.name || file.path || 'Thumbnail'; + return raw.split('/').pop() || raw; +} + +export default function BuilderPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + const queryClient = useQueryClient(); + const { + setStatus, + setBuilderCompleted, + registerBeforeNavigateHandler, + stepShowErrors, + } = useUseCaseEditStatus(); + + const usecaseId = Number.parseInt(params.id, 10); + const ownerArgs = { [params.entityType]: params.entitySlug }; + + const builderQuery = useQuery( + [`fetch_UseCaseBuilder`, params.id, params.entityType, params.entitySlug], + () => + GraphQL(FetchUseCaseBuilder, ownerArgs, { + filters: { id: params.id }, + }), + { refetchOnMount: true } + ); + + const dashboardsQuery = useQuery( + [`fetch_UseCaseBuilderDashboards`, params.id], + () => GraphQL(dashboardListQuery, ownerArgs, { usecaseId }), + { enabled: !Number.isNaN(usecaseId) } + ); + + const chartsQuery = useQuery([`use_case_wizard_charts`], () => + GraphQL(chartListQuery, ownerArgs) + ); + + const useCase = builderQuery.data?.useCases?.[0]; + const savedDashboard = dashboardsQuery.data?.usecaseDashboards?.[0]; + + const [title, setTitle] = useState(''); + const [logo, setLogo] = useState(null); + const [knownBytes, setKnownBytes] = useState(null); + const [remoteBytes, setRemoteBytes] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState(''); + const thumbnailInputRef = useRef(null); + const [contentDoc, setContentDoc] = useState({ + version: 1, + subtitle: '', + blocks: [], + }); + const [embedCode, setEmbedCode] = useState(''); + const contentDocRef = useRef(contentDoc); + contentDocRef.current = contentDoc; + const titleRef = useRef(title); + titleRef.current = title; + + useEffect(() => { + if (!useCase) return; + setTitle(useCase.title ?? ''); + setLogo(useCase.logo ?? null); + setContentDoc(parseUseCaseContent(useCase.summary)); + }, [useCase]); + + useEffect(() => { + if (savedDashboard?.link) { + setEmbedCode(savedDashboard.link); + } + }, [savedDashboard]); + + const { mutate: updateUseCase, isLoading: savingUseCase } = useMutation( + (data: { data: UseCaseInputPartial }) => + GraphQL(UpdateUseCaseBuilder, ownerArgs, data), + { + onSuccess: (res) => { + const next = res.updateUseCase; + if (next && 'logo' in next) { + setLogo(next.logo ?? null); + } + void queryClient.invalidateQueries({ + queryKey: [`usecase_wizard_${params.id}`], + }); + void queryClient.invalidateQueries({ + queryKey: [ + `fetch_UseCaseBuilder`, + params.id, + params.entityType, + params.entitySlug, + ], + }); + }, + onError: (error: unknown) => { + toast( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ? error.message + : 'Unable to save use case right now.' + ); + }, + } + ); + + const { mutate: addDashboard, isLoading: addingDashboard } = useMutation( + (input: { usecaseId: number; name: string; link: string }) => + GraphQL(addDashboardMutation, ownerArgs, input), + { + onSuccess: () => { + void dashboardsQuery.refetch(); + toast('Dashboard saved'); + }, + } + ); + + const { mutate: saveDashboard, isLoading: savingDashboard } = useMutation( + (input: { id: string; name: string; link: string }) => + GraphQL(updateDashboardMutation, ownerArgs, input), + { + onSuccess: () => { + void dashboardsQuery.refetch(); + toast('Dashboard saved'); + }, + } + ); + + const persistBuilder = useCallback( + (next?: { + title?: string; + document?: UseCaseContentDocument; + logo?: File | null; + }) => { + updateUseCase({ + data: { + id: params.id, + title: next?.title ?? titleRef.current, + summary: serializeUseCaseContent( + next?.document ?? contentDocRef.current + ), + ...(next && 'logo' in next ? { logo: next.logo } : {}), + }, + }); + }, + [params.id, updateUseCase] + ); + + const applyThumbnail = (file?: File) => { + if (!file) return; + if (file.size > MAX_THUMBNAIL_BYTES) { + toast('Image must be 20MB or smaller.'); + return; + } + setKnownBytes(file.size); + setLogo(file); + persistBuilder({ logo: file }); + }; + + const removeThumbnail = () => { + setKnownBytes(null); + setLogo(null); + persistBuilder({ logo: null }); + }; + + useEffect(() => { + registerBeforeNavigateHandler(() => persistBuilder()); + return () => registerBeforeNavigateHandler(null); + }, [persistBuilder, registerBeforeNavigateHandler]); + + useEffect(() => { + setStatus( + savingUseCase || addingDashboard || savingDashboard + ? 'loading' + : 'success' + ); + }, [savingUseCase, addingDashboard, savingDashboard, setStatus]); + + useEffect(() => { + setBuilderCompleted(isUseCaseBuilderComplete({ title, logo })); + }, [title, logo, setBuilderCompleted]); + + const handleSaveDashboard = () => { + const link = extractEmbedUrl(embedCode); + if (!link) { + toast('Paste a dashboard URL or iframe embed code first.'); + return; + } + const name = title.trim() || 'Use Case Dashboard'; + if (savedDashboard?.id) { + saveDashboard({ id: savedDashboard.id, name, link }); + return; + } + addDashboard({ usecaseId, name, link }); + }; + + useEffect(() => { + if (!logo) { + setThumbnailPreview(''); + return; + } + if (!(logo instanceof File)) { + setThumbnailPreview(mediaUrl(logo)); + return; + } + const url = URL.createObjectURL(logo); + setThumbnailPreview(url); + return () => URL.revokeObjectURL(url); + }, [logo]); + + useEffect(() => { + if (!logo || logo instanceof File) { + setRemoteBytes(null); + return; + } + const url = mediaUrl(logo); + if (!url) return; + const controller = new AbortController(); + fetch(url, { method: 'HEAD', signal: controller.signal }) + .then((response) => { + const length = Number(response.headers.get('content-length')); + if (Number.isFinite(length) && length > 0) setRemoteBytes(length); + }) + .catch(() => {}); + return () => controller.abort(); + }, [logo]); + + const thumbnailBytes = + logo instanceof File ? logo.size : (knownBytes ?? remoteBytes); + + const chartOptions = (chartsQuery.data?.getChartData ?? []) + .map((item) => { + const record = item as { + id?: string; + name?: string; + __typename?: string; + }; + return { + id: record.id ?? '', + name: record.name ?? record.id ?? 'Untitled chart', + kind: record.__typename, + }; + }) + .filter((item) => item.id); + + useEffect(() => { + const hash = window.location.hash; + if (!hash) return; + window.document.querySelector(hash)?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + if (builderQuery.isLoading) { + return ( +
+ +
+ ); + } + + const titleError = + stepShowErrors && !title.trim() ? 'Enter a use case title' : undefined; + const thumbnailError = + stepShowErrors && !logo ? 'Upload a thumbnail image' : undefined; + + return ( +
+ +
+
+
+ + Thumbnail + * + + + Upload an image that represents this Use Case. + +
+
+ { + applyThumbnail(event.target.files?.[0]); + event.target.value = ''; + }} + /> + {thumbnailPreview ? ( +
+
+ +
+ + {logo ? thumbnailName(logo) : 'Thumbnail'} + + {thumbnailBytes ? ( + + {formatFileSize(thumbnailBytes)} + + ) : null} +
+
+ + +
+
+ + JPG, PNG or WEBP. Max 20MB. + +
+ ) : ( + applyThumbnail(accepted[0])} + > + + + )} +
+
+ persistBuilder({ title })} + /> + + setContentDoc((prev) => ({ ...prev, subtitle: value })) + } + onBlur={() => + persistBuilder({ + document: { + ...contentDocRef.current, + subtitle: contentDoc.subtitle, + }, + }) + } + /> +
+
+ + +
+ + setContentDoc((prev) => ({ ...prev, blocks })) + } + onSave={(blocks) => + persistBuilder({ + document: { ...contentDocRef.current, blocks }, + }) + } + /> +
+
+ + +
+ + + Copy the iframe embed code from your dashboard provider, such as + Superset. + +
+ +
+ + You can add one external dashboard to this Use Case. + +
+
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/connect/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/connect/page.tsx new file mode 100644 index 00000000..fdd5f1af --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/connect/page.tsx @@ -0,0 +1,814 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { fetchDatasets } from '@/fetch'; +import { graphql } from '@/gql'; +import { UpdateUseCaseMetadataInput } from '@/gql/generated/graphql'; +import { organizationTypeLabel } from '@/hooks/useOrganizationTypes'; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { Combobox, SectionCard, Spinner, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { AddDatasetSheet } from '../../components/AddDatasetSheet'; +import { AddOrganisationSheet } from '../../components/AddOrganisationSheet'; +import { + EntityRowPicker, + type EntityRow, + type EntityRowPickerHandle, +} from '../../components/EntityRowPicker'; +import { useUseCaseEditStatus } from '../../context'; +import styles from '../../edit.module.scss'; +import { isUseCaseConnectComplete } from '../../usecase-summary'; +import { + AddContributors, + AddPartners, + FetchUsers, + OrgList, + RemoveContributor, + RemovePartners, +} from '../contributors/query'; + +interface SelectOption { + label: string; + value: string; +} + +interface ConnectFormData { + tags: SelectOption[]; + sdgs: SelectOption[]; + sectors: SelectOption[]; + geographies: SelectOption[]; +} + +interface SearchDataset { + id: string; + title: string; + description?: string; + status?: string; + sectors?: Array; + geographies?: Array; +} + +function firstLabel( + items?: Array | null +): string | undefined { + const first = items?.[0]; + if (!first) return undefined; + if (typeof first === 'string') return first; + return first.name || undefined; +} + +function datasetStatusLabel(status?: string | null) { + if (status === 'DRAFT' || status === 'draft') return 'Draft'; + if (status === 'ARCHIVED' || status === 'archived') return 'Archived'; + return 'Published'; +} + +function datasetMeta(item: { + sectors?: Array | null; + geographies?: Array | null; +}) { + return [firstLabel(item.sectors), firstLabel(item.geographies)] + .filter(Boolean) + .join(' · '); +} + +const DATASET_PAGE_SIZE = 20; + +const FetchUseCaseConnect = graphql(` + query UseCaseConnectData($filters: UseCaseFilter) { + useCases(filters: $filters) { + id + tags { + id + value + } + sectors { + id + name + } + geographies { + id + name + } + sdgs { + id + code + name + number + } + datasets { + id + title + description + status + sectors { + name + } + geographies { + name + } + } + contributors { + id + fullName + username + profilePicture { + url + } + } + partnerOrganizations { + id + name + organizationTypes + logo { + url + } + } + } + } +`); + +const UpdateConnectMetadata = graphql(` + mutation updateUseCaseConnectMetadata( + $updateMetadataInput: UpdateUseCaseMetadataInput! + ) { + addUpdateUsecaseMetadata(updateMetadataInput: $updateMetadataInput) { + ... on TypeUseCase { + id + tags { + id + value + } + sectors { + id + name + } + geographies { + id + name + } + sdgs { + id + code + name + number + } + } + } + } +`); + +const AssignDatasets = graphql(` + mutation assignUseCaseWizardDatasets( + $useCaseId: String! + $datasetIds: [UUID!]! + ) { + updateUsecaseDatasets(useCaseId: $useCaseId, datasetIds: $datasetIds) { + ... on TypeUseCase { + id + datasets { + id + title + } + } + } + } +`); + +const sectorsListQuery = graphql(` + query UseCaseWizardSectors { + sectors { + id + name + } + } +`); + +const geographiesListQuery = graphql(` + query UseCaseWizardGeographies { + geographies { + id + name + parentId { + name + } + } + } +`); + +const tagsListQuery = graphql(` + query UseCaseWizardTags { + tags { + id + value + } + } +`); + +const sdgsListQuery = graphql(` + query UseCaseWizardSdgs { + sdgs { + id + code + name + number + } + } +`); + +function comboValues(value: SelectOption[], key: 'value' | 'label'): string[] { + return value.map((item) => String(item[key] ?? '')); +} + +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/', '')}`; +} + +function sdgLabel(item: { + number?: number | null; + code?: string | null; + name?: string | null; +}) { + const num = item.number + ? String(item.number).padStart(2, '0') + : (item.code ?? '').replace('SDG', '').padStart(2, '0'); + return `${num}. ${item.name ?? ''}`; +} + +export default function ConnectPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + id: string; + }>(); + const queryClient = useQueryClient(); + const { + setStatus, + setConnectCompleted, + registerBeforeNavigateHandler, + stepShowErrors, + } = useUseCaseEditStatus(); + const ownerArgs = { [params.entityType]: params.entitySlug }; + + const [formData, setFormData] = useState({ + tags: [], + sdgs: [], + sectors: [], + geographies: [], + }); + const formRef = useRef(formData); + formRef.current = formData; + const [userSearch, setUserSearch] = useState(''); + const [datasetSearch, setDatasetSearch] = useState(''); + const [debouncedDatasetSearch, setDebouncedDatasetSearch] = useState(''); + const [datasetSheetOpen, setDatasetSheetOpen] = useState(false); + const [creatingDataset, setCreatingDataset] = useState(false); + const [orgSheetOpen, setOrgSheetOpen] = useState(false); + const [creatingOrganisation, setCreatingOrganisation] = useState(false); + const datasetsPickerRef = useRef(null); + const contributorsPickerRef = useRef(null); + const orgsPickerRef = useRef(null); + + const useCaseQuery = useQuery( + [`fetch_UseCaseConnect`, params.id, params.entityType, params.entitySlug], + () => + GraphQL(FetchUseCaseConnect, ownerArgs, { + filters: { id: params.id }, + }), + { refetchOnMount: true } + ); + + const sectorsQuery = useQuery([`use_case_wizard_sectors`], () => + GraphQL(sectorsListQuery, ownerArgs) + ); + const tagsQuery = useQuery([`use_case_wizard_tags`], () => + GraphQL(tagsListQuery, ownerArgs) + ); + const sdgsQuery = useQuery([`use_case_wizard_sdgs`], () => + GraphQL(sdgsListQuery, ownerArgs) + ); + const geosQuery = useQuery([`use_case_wizard_geos`], () => + GraphQL(geographiesListQuery, ownerArgs) + ); + const usersQuery = useQuery( + [`use_case_wizard_users`, userSearch], + () => + GraphQL(FetchUsers, ownerArgs, { + limit: 10, + searchTerm: userSearch, + }), + { enabled: userSearch.length > 0, keepPreviousData: true } + ); + const orgsQuery = useQuery([`use_case_wizard_orgs`], () => + GraphQL(OrgList, ownerArgs) + ); + + const datasetsQuery = useInfiniteQuery( + [`use_case_wizard_datasets`, debouncedDatasetSearch], + async ({ pageParam = 1 }) => { + const params = new URLSearchParams({ + size: String(DATASET_PAGE_SIZE), + page: String(pageParam), + sort: 'recent', + }); + if (debouncedDatasetSearch) { + params.set('query', debouncedDatasetSearch); + } + const res = (await fetchDatasets(`?${params.toString()}`)) as { + results?: SearchDataset[]; + total?: number; + }; + return { + results: res.results ?? [], + total: Number(res.total ?? 0), + }; + }, + { + keepPreviousData: true, + staleTime: 0, + cacheTime: 0, + refetchOnMount: 'always', + getNextPageParam: (lastPage, allPages) => { + if (lastPage.results.length < DATASET_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 loadMoreDatasets = useCallback(() => { + if (!datasetsQuery.hasNextPage || datasetsQuery.isFetchingNextPage) return; + void datasetsQuery.fetchNextPage(); + }, [ + datasetsQuery.fetchNextPage, + datasetsQuery.hasNextPage, + datasetsQuery.isFetchingNextPage, + ]); + + const useCase = useCaseQuery.data?.useCases?.[0]; + + useEffect(() => { + if (!useCase) return; + setFormData({ + tags: + useCase.tags?.map((item) => ({ + label: item.value ?? '', + value: item.id, + })) ?? [], + sdgs: + useCase.sdgs?.map((item) => ({ + label: sdgLabel(item), + value: item.id, + })) ?? [], + sectors: + useCase.sectors?.map((item) => ({ + label: item.name ?? '', + value: item.id, + })) ?? [], + geographies: + useCase.geographies?.map((item) => ({ + label: item.name ?? '', + value: item.id, + })) ?? [], + }); + }, [useCase]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedDatasetSearch(datasetSearch.trim()); + }, 300); + return () => window.clearTimeout(timer); + }, [datasetSearch]); + + const { mutate: saveMetadata, isLoading: savingMetadata } = useMutation( + (input: UpdateUseCaseMetadataInput) => + GraphQL(UpdateConnectMetadata, ownerArgs, { + updateMetadataInput: input, + }), + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [`usecase_wizard_${params.id}`], + }); + void useCaseQuery.refetch(); + void tagsQuery.refetch(); + }, + onError: (error: unknown) => { + toast( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ? error.message + : 'Unable to save classification right now.' + ); + }, + } + ); + + const persistMetadata = useCallback( + (next = formRef.current) => { + saveMetadata({ + id: params.id, + metadata: [], + 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) + ), + }); + }, + [params.id, saveMetadata] + ); + + const handleClassChange = ( + field: keyof ConnectFormData, + value: SelectOption[] | string + ) => { + const nextValue = Array.isArray(value) ? value : []; + const next = { ...formRef.current, [field]: nextValue }; + setFormData(next); + persistMetadata(next); + }; + + const { mutate: assignDatasets, isLoading: savingDatasets } = useMutation( + (datasetIds: string[]) => + GraphQL(AssignDatasets, ownerArgs, { + useCaseId: params.id, + datasetIds, + }), + { + onSuccess: () => { + void useCaseQuery.refetch(); + }, + } + ); + + const { mutate: addContributor, isLoading: addingContributor } = useMutation( + (userId: string) => + GraphQL(AddContributors, ownerArgs, { + useCaseId: params.id, + userId, + }), + { onSuccess: () => void useCaseQuery.refetch() } + ); + + const { mutate: removeContributor, isLoading: removingContributor } = + useMutation( + (userId: string) => + GraphQL(RemoveContributor, ownerArgs, { + useCaseId: params.id, + userId, + }), + { onSuccess: () => void useCaseQuery.refetch() } + ); + + const { mutate: addPartner, isLoading: addingPartner } = useMutation( + (organizationId: string) => + GraphQL(AddPartners, ownerArgs, { + useCaseId: params.id, + organizationId, + }), + { onSuccess: () => void useCaseQuery.refetch() } + ); + + const { mutate: removePartner, isLoading: removingPartner } = useMutation( + (organizationId: string) => + GraphQL(RemovePartners, ownerArgs, { + useCaseId: params.id, + organizationId, + }), + { onSuccess: () => void useCaseQuery.refetch() } + ); + + useEffect(() => { + registerBeforeNavigateHandler(() => persistMetadata()); + return () => registerBeforeNavigateHandler(null); + }, [persistMetadata, registerBeforeNavigateHandler]); + + useEffect(() => { + setStatus( + savingMetadata || + savingDatasets || + addingContributor || + removingContributor || + addingPartner || + removingPartner || + creatingDataset || + creatingOrganisation + ? 'loading' + : 'success' + ); + }, [ + savingMetadata, + savingDatasets, + addingContributor, + removingContributor, + addingPartner, + removingPartner, + creatingDataset, + creatingOrganisation, + setStatus, + ]); + + useEffect(() => { + setConnectCompleted( + isUseCaseConnectComplete({ + sectors: formData.sectors, + sdgs: formData.sdgs, + }) + ); + }, [formData.sectors, formData.sdgs, setConnectCompleted]); + + useEffect(() => { + const hash = window.location.hash; + if (!hash) return; + window.document.querySelector(hash)?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + if ( + useCaseQuery.isLoading || + sectorsQuery.isLoading || + tagsQuery.isLoading || + sdgsQuery.isLoading || + geosQuery.isLoading + ) { + return ( +
+ +
+ ); + } + + const selectedDatasets: EntityRow[] = + useCase?.datasets?.map((item) => ({ + id: item.id, + title: item.title ?? 'Untitled dataset', + subtitle: datasetMeta(item), + summary: item.description ?? undefined, + badge: datasetStatusLabel(item.status), + })) ?? []; + + const datasetOptions: EntityRow[] = + datasetsQuery.data?.pages.flatMap((page) => + page.results.map((item) => ({ + id: String(item.id), + title: item.title, + subtitle: datasetMeta(item), + summary: item.description, + badge: datasetStatusLabel(item.status), + })) + ) ?? []; + + const selectedContributors: EntityRow[] = + useCase?.contributors?.map((item) => ({ + id: item.id, + title: item.fullName, + subtitle: item.username, + imageUrl: fileUrl(item.profilePicture?.url), + })) ?? []; + + const contributorOptions: EntityRow[] = + usersQuery.data?.searchUsers?.map((item) => ({ + id: item.id, + title: item.fullName, + subtitle: item.username, + imageUrl: fileUrl(item.profilePicture?.url) || undefined, + })) ?? []; + + const selectedOrgs: EntityRow[] = + useCase?.partnerOrganizations?.map((item) => ({ + id: item.id, + title: item.name, + subtitle: organizationTypeLabel(item.organizationTypes), + imageUrl: fileUrl(item.logo?.url) || undefined, + })) ?? []; + + const orgOptions: EntityRow[] = + orgsQuery.data?.allOrganizations?.map((item) => ({ + id: item.id, + title: item.name, + subtitle: organizationTypeLabel(item.organizationTypes), + imageUrl: fileUrl(item.logo?.url) || undefined, + })) ?? []; + + const sdgError = + stepShowErrors && formData.sdgs.length === 0 + ? 'Select at least one SDG goal' + : undefined; + const sectorError = + stepShowErrors && formData.sectors.length === 0 + ? 'Select at least one sector' + : undefined; + + return ( +
+ +
+ ({ + label: item.value ?? '', + value: item.id, + })) || [] + } + selectedValue={formData.tags} + onChange={(value) => handleClassChange('tags', value)} + /> + ({ + label: sdgLabel(item), + value: item.id, + })) || [] + } + selectedValue={formData.sdgs} + onChange={(value) => handleClassChange('sdgs', value)} + /> + ({ + label: item.name, + value: item.id, + })) || [] + } + selectedValue={formData.sectors} + onChange={(value) => handleClassChange('sectors', value)} + /> + ({ + label: `${item.name}${item.parentId ? ` (${item.parentId.name})` : ''}`, + value: item.id, + })) || [] + } + selectedValue={formData.geographies} + onChange={(value) => handleClassChange('geographies', value)} + /> +
+
+ + setDatasetSheetOpen(true), + }, + ]} + > +
+ { + assignDatasets([ + ...selectedDatasets.map((row) => row.id), + item.id, + ]); + }} + onRemove={(id) => { + assignDatasets( + selectedDatasets + .filter((row) => row.id !== id) + .map((row) => row.id) + ); + }} + /> +
+
+ + contributorsPickerRef.current?.focus(), + }, + ]} + > +
+ 0 && usersQuery.isFetching} + onSearch={setUserSearch} + onAdd={(item) => addContributor(item.id)} + onRemove={(id) => removeContributor(id)} + /> +
+
+ + setOrgSheetOpen(true), + }, + ]} + > +
+ addPartner(item.id)} + onRemove={(id) => removePartner(id)} + /> +
+
+ + setOrgSheetOpen(false)} + useCaseId={params.id} + ownerArgs={ownerArgs} + onBusy={setCreatingOrganisation} + onAdded={() => { + void orgsQuery.refetch(); + void useCaseQuery.refetch(); + }} + /> + setDatasetSheetOpen(false)} + useCaseId={params.id} + ownerArgs={ownerArgs} + sectors={ + sectorsQuery.data?.sectors?.map((item) => ({ + label: item.name, + value: String(item.id), + })) ?? [] + } + connectedDatasetIds={selectedDatasets.map((row) => row.id)} + onBusy={setCreatingDataset} + onAdded={() => { + void useCaseQuery.refetch(); + }} + /> +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/page.tsx index 4d566fc1..d1ffb362 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/page.tsx @@ -1,577 +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 { RedirectToUseCaseStep } from '../../RedirectToUseCaseStep'; -import { GraphQL } from '@/lib/api'; -import { Icons } from '@/components/icons'; -import { Loading } from '@/components/loading'; -import { useEditStatus } from '../../context'; -import CustomCombobox from './CustomCombobox'; -import EntitySection from './EntitySelection'; -import { - AddContributors, - AddPartners, - AddSupporters, - FetchUsecaseInfo, - FetchUsers, - OrgList, - RemoveContributor, - RemovePartners, - RemoveSupporters, -} from './query'; - -const Details = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const CONTRIBUTORS_ADD_SUCCESS_TOAST_ID = 'usecase-contributor-add-success'; - const CONTRIBUTORS_ADD_ERROR_TOAST_ID = 'usecase-contributor-add-error'; - const CONTRIBUTORS_REMOVE_SUCCESS_TOAST_ID = - 'usecase-contributor-remove-success'; - const CONTRIBUTORS_REMOVE_ERROR_TOAST_ID = 'usecase-contributor-remove-error'; - const SUPPORTER_ADD_SUCCESS_TOAST_ID = 'usecase-supporter-add-success'; - const SUPPORTER_ADD_ERROR_TOAST_ID = 'usecase-supporter-add-error'; - const SUPPORTER_REMOVE_SUCCESS_TOAST_ID = 'usecase-supporter-remove-success'; - const SUPPORTER_REMOVE_ERROR_TOAST_ID = 'usecase-supporter-remove-error'; - const PARTNER_ADD_SUCCESS_TOAST_ID = 'usecase-partner-add-success'; - const PARTNER_ADD_ERROR_TOAST_ID = 'usecase-partner-add-error'; - const PARTNER_REMOVE_SUCCESS_TOAST_ID = 'usecase-partner-remove-success'; - const PARTNER_REMOVE_ERROR_TOAST_ID = 'usecase-partner-remove-error'; - const getErrorMessage = (error: unknown, fallback: string) => - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message.trim() - ? error.message.trim() - : fallback; - 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 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 UseCaseData = useQuery( - [`fetch_usecase`, params.id, params.entityType, params.entitySlug], - () => - GraphQL( - FetchUsecaseInfo, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const [prevUseCaseData, setPrevUseCaseData] = useState< - typeof UseCaseData.data | undefined - >(undefined); - if (UseCaseData.data !== prevUseCaseData) { - setPrevUseCaseData(UseCaseData.data); - setFormData((prev) => ({ - ...prev, - partners: - UseCaseData?.data?.useCases?.[0]?.partnerOrganizations?.map( - (org) => ({ - label: org.name, - value: org.id, - }) - ) || [], - supporters: - UseCaseData?.data?.useCases?.[0]?.supportingOrganizations?.map( - (org) => ({ - label: org.name, - value: org.id, - }) - ) || [], - contributors: - UseCaseData?.data?.useCases?.[0]?.contributors?.map((user) => ({ - label: user.fullName, - value: user.id, - })) || [], - })); - } - - const queryClient = useQueryClient(); - const invalidateUseCaseQueries = () => { - queryClient.invalidateQueries({ - queryKey: [ - `fetch_usecase`, - params.id, - params.entityType, - params.entitySlug, - ], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_UsecaseDetails`, - params.id, - params.entityType, - params.entitySlug, - ], - }); - }; - - const { mutate: addContributor, isLoading: addContributorLoading } = - useMutation( - (input: { useCaseId: string; userId: string }) => - GraphQL( - AddContributors, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Contributor added successfully', { - id: CONTRIBUTORS_ADD_SUCCESS_TOAST_ID, - }); - invalidateUseCaseQueries(); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to add contributor right now. Please try again.')}`, - { id: CONTRIBUTORS_ADD_ERROR_TOAST_ID } - ); - }, - } - ); - - const { mutate: removeContributor, isLoading: removeContributorLoading } = - useMutation( - (input: { useCaseId: string; userId: string }) => - GraphQL( - RemoveContributor, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Contributor removed successfully', { - id: CONTRIBUTORS_REMOVE_SUCCESS_TOAST_ID, - }); - invalidateUseCaseQueries(); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to remove contributor right now. Please try again.')}`, - { id: CONTRIBUTORS_REMOVE_ERROR_TOAST_ID } - ); - }, - } - ); - - const { mutate: addSupporter, isLoading: addSupporterLoading } = useMutation( - (input: { useCaseId: string; organizationId: string }) => - GraphQL( - AddSupporters, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Supporter added successfully', { - id: SUPPORTER_ADD_SUCCESS_TOAST_ID, - }); - invalidateUseCaseQueries(); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to add supporter right now. Please try again.')}`, - { id: SUPPORTER_ADD_ERROR_TOAST_ID } - ); - }, - } - ); - - const { mutate: removeSupporter, isLoading: removeSupporterLoading } = - useMutation( - (input: { useCaseId: string; organizationId: string }) => - GraphQL( - RemoveSupporters, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Supporter removed successfully', { - id: SUPPORTER_REMOVE_SUCCESS_TOAST_ID, - }); - invalidateUseCaseQueries(); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to remove supporter right now. Please try again.')}`, - { id: SUPPORTER_REMOVE_ERROR_TOAST_ID } - ); - }, - } - ); - - const { mutate: addPartner, isLoading: addPartnerLoading } = useMutation( - (input: { useCaseId: string; organizationId: string }) => - GraphQL( - AddPartners, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Partner added successfully', { - id: PARTNER_ADD_SUCCESS_TOAST_ID, - }); - invalidateUseCaseQueries(); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to add partner right now. Please try again.')}`, - { id: PARTNER_ADD_ERROR_TOAST_ID } - ); - }, - } - ); - - const { mutate: removePartner, isLoading: removePartnerLoading } = - useMutation( - (input: { useCaseId: string; organizationId: string }) => - GraphQL( - RemovePartners, - { - [params.entityType]: params.entitySlug, - }, - input - ), - { - onSuccess: () => { - toast('Partner removed successfully', { - id: PARTNER_REMOVE_SUCCESS_TOAST_ID, - }); - invalidateUseCaseQueries(); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to remove partner right now. Please try again.')}`, - { id: PARTNER_REMOVE_ERROR_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({ - useCaseId: 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}/${ - UseCaseData.data?.useCases?.[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({ - useCaseId: params.id, - organizationId: newlyAdded.value, - }); - } - }} - onRemove={(item) => { - setFormData((prev) => ({ - ...prev, - supporters: prev.supporters.filter( - (s) => s.value !== item.value - ), - })); - removeSupporter({ - useCaseId: 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({ - useCaseId: params.id, - organizationId: newlyAdded.value, - }); - } - }} - onRemove={(item) => { - setFormData((prev) => ({ - ...prev, - partners: prev.partners.filter((s) => s.value !== item.value), - })); - removePartner({ - useCaseId: params.id, - organizationId: item.value, - }); - }} - /> -
- )} -
- ); -}; - -export default Details; - -{ - /*
-
- { - setFormData((prev) => ({ - ...prev, - contributors: [...prev.contributors, ...value], - })); - setSearchValue(''); // clear input - }} - onInput={(value: string) => { - console.log(value); - setSearchValue(value); - }} - key={Users?.data?.searchUsers?.length} - /> - - (Some Contributors have been preselected from added Datasets) - -
-
*/ -} -{ - /* ({ - label: org.name, - value: org.name, - })) - } - selectedValue={formData.partners} - onChange={(value) => { - setFormData((prev) => ({ - ...prev, - partners: value, - })); - }} - /> */ +export default function ContributorsRedirect() { + return ; } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/query.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/query.ts index 33f7c78f..f005442d 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/query.ts +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/query.ts @@ -7,6 +7,9 @@ export const FetchUsers = graphql(` id fullName username + profilePicture { + url + } } } `); @@ -176,10 +179,11 @@ export const RemovePartners = graphql(` export const OrgList = graphql(` - query allOrgs { + query UseCaseWizardOrgs { allOrganizations { id name + organizationTypes logo { path url diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/dashboards/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/dashboards/page.tsx index 3050efb8..ea58f894 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/dashboards/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/dashboards/page.tsx @@ -1,329 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { graphql } from '@/gql'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, Icon, Spinner, Text, TextField, toast } from 'opub-ui'; +import { RedirectToUseCaseStep } from '../../RedirectToUseCaseStep'; -import { GraphQL } from '@/lib/api'; -import { Icons } from '@/components/icons'; -import { useEditStatus } from '../../context'; - -const dashboardList = graphql(` - query dashboardList($usecaseId: Int!) { - usecaseDashboards(usecaseId: $usecaseId) { - id - name - link - created - } - } -`); - -const AddDashboard = graphql(` - mutation addDashboard($usecaseId: Int!, $name: String, $link: String) { - addUsecaseDashboard(usecaseId: $usecaseId, name: $name, link: $link) { - success - errors { - fieldErrors { - field - messages - } - } - data { - id - name - link - } - } - } -`); - -const updateDashboard = graphql(` - mutation updateDashboard($id: String!, $name: String, $link: String) { - updateUsecaseDashboard(id: $id, name: $name, link: $link) { - success - errors { - fieldErrors { - field - messages - } - } - data { - id - name - link - } - } - } -`); - -const deleteDashboard = graphql(` - mutation deleteUsecaseDashboard($id: Int!) { - deleteUsecaseDashboard(id: $id) { - success - errors { - fieldErrors { - field - messages - } - } - } - } -`); - -const Dashboard = () => { - const params = useParams<{ - entityType?: string; - entitySlug?: string; - id?: string; - }>(); - const DASHBOARD_ADD_SUCCESS_TOAST_ID = 'usecase-dashboard-add-success'; - const DASHBOARD_SAVE_SUCCESS_TOAST_ID = 'usecase-dashboard-save-success'; - const DASHBOARD_DELETE_SUCCESS_TOAST_ID = 'usecase-dashboard-delete-success'; - const DASHBOARD_SAVE_ERROR_TOAST_ID = 'usecase-dashboard-save-error'; - const DASHBOARD_DELETE_ERROR_TOAST_ID = 'usecase-dashboard-delete-error'; - const getErrorMessage = (error: unknown, fallback: string) => - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message.trim() - ? error.message.trim() - : fallback; - const entityType = params?.entityType; - const entitySlug = params?.entitySlug; - const idParam = params?.id; - - const isValidParams = - typeof entityType === 'string' && - typeof entitySlug === 'string' && - typeof idParam === 'string'; - - const usecaseId = - isValidParams && idParam ? Number.parseInt(idParam, 10) : NaN; - - const isValidId = !Number.isNaN(usecaseId) && isValidParams; - - const ownerArgs: Record | null = isValidParams - ? { [entityType]: entitySlug } - : null; - - const [dashboards, setDashboards] = useState< - Array<{ id: string; name: string; link: string }> - >([]); - const [previousState, setPreviousState] = useState< - Record - >({}); - - const queryKey = [ - 'fetch_dashboardData', - params.entityType, - params.entitySlug, - usecaseId, - ]; - - const { data: dashboardData, isLoading } = - useQuery( - queryKey, - () => GraphQL(dashboardList, ownerArgs || {}, { usecaseId }), - { - refetchOnMount: true, - refetchOnReconnect: true, - enabled: isValidId, - } - ); - - const [prevDashboardData, setPrevDashboardData] = useState< - typeof dashboardData | undefined - >(undefined); - if (dashboardData !== prevDashboardData) { - setPrevDashboardData(dashboardData); - if (dashboardData?.usecaseDashboards) { - setDashboards(dashboardData.usecaseDashboards); - setPreviousState( - Object.fromEntries( - dashboardData.usecaseDashboards.map((item) => [item.id, { ...item }]) - ) - ); - } - } - - const queryClient = useQueryClient(); - const invalidateDashboardQueries = () => { - queryClient.invalidateQueries({ queryKey }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_UsecaseDetails`, - params.id, - params.entityType, - params.entitySlug, - ], - }); - }; - - const { mutate: addDashboard, isLoading: addLoading } = useMutation( - ({ usecaseId }: { usecaseId: number }) => - GraphQL(AddDashboard, ownerArgs || {}, { usecaseId }), - { - onSuccess: () => { - invalidateDashboardQueries(); - toast.success('Dashboard added', { - id: DASHBOARD_ADD_SUCCESS_TOAST_ID, - }); - }, - } - ); - const { mutate: saveDashboard, isLoading: saveLoading } = useMutation( - ({ id, name, link }: { id: string; name: string; link: string }) => - GraphQL(updateDashboard, ownerArgs || {}, { id, name, link }), - { - onSuccess: ({ updateUsecaseDashboard }) => { - invalidateDashboardQueries(); - toast.success('Changes saved', { id: DASHBOARD_SAVE_SUCCESS_TOAST_ID }); - const dashboard = updateUsecaseDashboard.data; - if (!dashboard?.id) return; - setPreviousState((prev) => ({ - ...prev, - [dashboard.id]: { - id: dashboard.id, - name: dashboard.name ?? '', - link: dashboard.link ?? '', - }, - })); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to save dashboard changes right now. Please try again.')}`, - { id: DASHBOARD_SAVE_ERROR_TOAST_ID } - ); - }, - } - ); - - const { mutate: removeDashboard, isLoading: deleteLoading } = useMutation( - (id: number) => GraphQL(deleteDashboard, ownerArgs || {}, { id }), - { - onSuccess: () => { - toast.success('Dashboard deleted', { - id: DASHBOARD_DELETE_SUCCESS_TOAST_ID, - }); - invalidateDashboardQueries(); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to delete dashboard right now. Please try again.')}`, - { id: DASHBOARD_DELETE_ERROR_TOAST_ID } - ); - }, - } - ); - - const { setStatus } = useEditStatus(); - - useEffect(() => { - setStatus( - saveLoading || addLoading || deleteLoading ? 'loading' : 'success' - ); // update based on mutation state - }, [saveLoading, addLoading, deleteLoading, setStatus]); - - if (!isValidId) { - return null; - } - - const handleChange = (id: string, field: 'name' | 'link', value: string) => { - setDashboards((prev) => - prev.map((d) => (d.id === id ? { ...d, [field]: value } : d)) - ); - }; - - const handleSave = (dashboard: { - id: string; - name: string; - link: string; - }) => { - const prev = previousState[dashboard.id]; - if (dashboard.name !== prev?.name || dashboard.link !== prev?.link) { - saveDashboard({ - id: dashboard.id, - name: dashboard.name, - link: dashboard.link, - }); - } - }; - - const handleAdd = () => { - addDashboard({ usecaseId }); - }; - - const handleDelete = (id: string) => { - removeDashboard(Number(id)); - }; - - return ( -
- Dashboard - - Link external analytical dashboards to your use case. To create a new - dashboard, go to{' '} - - CivicDataSpace Analytics - - . - - - {isLoading ? ( -
- -
- ) : ( - <> - {dashboards?.map((item) => ( -
-
- handleChange(item.id, 'name', e)} - onBlur={() => handleSave(item)} - /> -
-
- handleChange(item.id, 'link', e)} - onBlur={() => handleSave(item)} - /> -
- -
- ))} - - - )} -
- ); -}; - -export default Dashboard; +export default function DashboardsRedirect() { + return ; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx index 7c721632..09a2dd82 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx @@ -1,437 +1,7 @@ 'use client'; -import React, { useCallback, useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { graphql } from '@/gql'; -import { UseCaseInputPartial, UseCaseRunningStatus } from '@/gql/generated/graphql'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { DropZone, Select, TextField, toast } from 'opub-ui'; +import { RedirectToUseCaseStep } from '../../RedirectToUseCaseStep'; -// Assuming you are using these components - -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 UseCaseFormData { - title: string; - summary: string; - logo: File | UploadedImage | null; - website: string; - contactEmail: string; - slug: string; - status: string; - runningStatus: string | null; - startedOn: string | null; - completedOn: string | null; - platformUrl: string; +export default function DetailsRedirect() { + return ; } - -const UpdateUseCaseMutation = graphql(` - mutation updateUseCase($data: UseCaseInputPartial!) { - updateUseCase(data: $data) { - __typename - id - title - summary - created - modified - website - runningStatus - slug - status - startedOn - completedOn - platformUrl - logo { - name - path - url - } - } - } -`); - -const FetchUseCase = graphql(` - query UseCaseData($filters: UseCaseFilter) { - useCases(filters: $filters) { - id - title - summary - website - platformUrl - logo { - name - path - url - } - runningStatus - contactEmail - status - slug - startedOn - completedOn - } - } -`); - -const Details = () => { - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const USECASE_EDIT_SUCCESS_TOAST_ID = 'usecase-edit-save-success'; - const USECASE_DETAILS_ERROR_TOAST_ID = 'usecase-details-save-error'; - const getErrorMessage = (error: unknown, fallback: string) => - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message.trim() - ? error.message.trim() - : fallback; - - const UseCaseData = useQuery( - [ - `fetch_UseCaseData_details`, - params.id, - params.entityType, - params.entitySlug, - ], - () => - GraphQL( - FetchUseCase, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const UsecasesData = - (UseCaseData?.data?.useCases?.length ?? 0) > 0 && UseCaseData?.data?.useCases?.[0]; - - const initialFormData = { - title: '', - summary: '', - logo: null as File | UploadedImage | null, - website: '', - contactEmail: '', - slug: '', - status: '', - runningStatus: null as string | null, - startedOn: null as string | null, - completedOn: null as string | null, - platformUrl: '', - }; - - const runningStatus = [ - { - label: 'Initiated', - value: 'INITIATED', - }, - { - label: 'On Going', - value: 'ON_GOING', - }, - { - label: 'Completed', - value: 'COMPLETED', - }, - { - label: 'Cancelled', - value: 'CANCELLED', - }, - ]; - - const [formData, setFormData] = useState(initialFormData); - - const [, setPreviousFormData] = useState(initialFormData); - - const [prevUsecasesData, setPrevUsecasesData] = useState< - typeof UsecasesData | undefined - >(undefined); - const [prevDetailsId, setPrevDetailsId] = useState(params.id); - if (params.id !== prevDetailsId || UsecasesData !== prevUsecasesData) { - setPrevDetailsId(params.id); - setPrevUsecasesData(UsecasesData); - if (UsecasesData) { - const updatedData = { - title: UsecasesData.title || '', - summary: UsecasesData.summary || '', - logo: UsecasesData.logo || null, - website: UsecasesData.website || '', - contactEmail: UsecasesData.contactEmail || '', - slug: UsecasesData.slug || '', - status: UsecasesData.status || '', - runningStatus: UsecasesData.runningStatus || null, - startedOn: UsecasesData.startedOn || '', - completedOn: UsecasesData.completedOn || '', - platformUrl: UsecasesData.platformUrl || '', - }; - setFormData(updatedData); - setPreviousFormData(updatedData); - } - } - - const queryClient = useQueryClient(); - - const { mutate, isLoading: editMutationLoading } = useMutation( - (data: { data: UseCaseInputPartial }) => - GraphQL( - UpdateUseCaseMutation, - { - [params.entityType]: params.entitySlug, - }, - data - ), - { - onSuccess: (res) => { - toast('Use case updated successfully', { - id: USECASE_EDIT_SUCCESS_TOAST_ID, - }); - setFormData((prev) => ({ - ...prev, - title: res.updateUseCase.title ?? prev.title, - summary: res.updateUseCase.summary ?? prev.summary, - logo: res.updateUseCase.logo ?? prev.logo, - website: res.updateUseCase.website ?? prev.website, - slug: res.updateUseCase.slug ?? prev.slug, - status: res.updateUseCase.status ?? prev.status, - runningStatus: res.updateUseCase.runningStatus ?? prev.runningStatus, - startedOn: res.updateUseCase.startedOn ?? prev.startedOn, - completedOn: res.updateUseCase.completedOn ?? prev.completedOn, - platformUrl: res.updateUseCase.platformUrl ?? prev.platformUrl, - })); - setPreviousFormData((prev) => ({ - ...prev, - title: res.updateUseCase.title ?? prev.title, - summary: res.updateUseCase.summary ?? prev.summary, - logo: res.updateUseCase.logo ?? prev.logo, - website: res.updateUseCase.website ?? prev.website, - slug: res.updateUseCase.slug ?? prev.slug, - status: res.updateUseCase.status ?? prev.status, - runningStatus: res.updateUseCase.runningStatus ?? prev.runningStatus, - startedOn: res.updateUseCase.startedOn ?? prev.startedOn, - completedOn: res.updateUseCase.completedOn ?? prev.completedOn, - platformUrl: res.updateUseCase.platformUrl ?? prev.platformUrl, - })); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_UseCaseData_details`, - params.id, - params.entityType, - params.entitySlug, - ], - }); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_UsecaseDetails`, - params.id, - params.entityType, - params.entitySlug, - ], - }); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to update use case right now. Please try again.')}`, - { id: USECASE_DETAILS_ERROR_TOAST_ID } - ); - }, - } - ); - - const handleChange = useCallback( - (field: keyof UseCaseFormData, value: UseCaseFormData[keyof UseCaseFormData]) => { - 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 handleSave = (updatedData: UseCaseFormData) => { - const updatedSnapshot = JSON.stringify(updatedData); - setPreviousFormData((prevData) => { - if (JSON.stringify(prevData) === updatedSnapshot) { - return prevData; - } - - mutate({ - data: { - id: params.id.toString(), - title: updatedData.title, - summary: updatedData.summary, - website: updatedData.website, - contactEmail: updatedData.contactEmail, - runningStatus: - updatedData.runningStatus && - (Object.values(UseCaseRunningStatus) as string[]).includes( - updatedData.runningStatus - ) - ? (updatedData.runningStatus as UseCaseRunningStatus) - : null, - startedOn: updatedData.startedOn || null, - completedOn: updatedData.completedOn || null, - platformUrl: updatedData.platformUrl || '', - }, - }); - - return updatedData; - }); - }; - const { setStatus } = useEditStatus(); - - useEffect(() => { - setStatus(editMutationLoading ? 'loading' : 'success'); // update based on mutation state - }, [editMutationLoading, setStatus]); - - return ( -
-
- handleChange('summary', value)} - onBlur={(value) => handleSave({ ...formData, summary: value })} - placeholder="Enter use case summary with rich formatting..." - helpText={`Character limit: ${formData?.summary?.length || 0}/10000`} - /> -
-
-
- handleChange('platformUrl', e)} - onBlur={() => handleSave(formData)} - /> -
-
- ({ + label: option.label, + value: option.value, + }))} + value={form.license} + error={errors.license} + onChange={(value) => { + if (!isLicense(value)) return; + setForm((current) => ({ ...current, license: value })); + }} + /> +
+ +
+
+ +
+ + +
+ + + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddOrganisationSheet.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddOrganisationSheet.tsx new file mode 100644 index 00000000..79647d62 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddOrganisationSheet.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AddPartners } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/contributors/query'; +import { organizationCreationMutation } from '@/app/[locale]/dashboard/[entityType]/schema'; +import { + ApiOrganizationOrganizationTypesEnum, + OrganizationInput, +} from '@/gql/generated/graphql'; +import { useOrganizationTypes } from '@/hooks/useOrganizationTypes'; +import { IconX } from '@tabler/icons-react'; +import { useMutation } from '@tanstack/react-query'; +import { + Button, + DropZone, + Icon, + IconButton, + Select, + Sheet, + Text, + TextField, + toast, +} from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { Icons } from '@/components/icons'; + +const initialFormData = { + name: '', + description: '', + logo: null as File | null, + homepage: '', + contactEmail: '', + linkedinProfile: '', + githubProfile: '', + twitterProfile: '', + location: '', + organizationTypes: ApiOrganizationOrganizationTypesEnum.StateGovernment, +}; + +type AddOrganisationSheetProps = { + open: boolean; + onClose: () => void; + useCaseId: string; + ownerArgs: Record; + onAdded: () => void; + onBusy?: (busy: boolean) => void; +}; + +export function AddOrganisationSheet({ + open, + onClose, + useCaseId, + ownerArgs, + onAdded, + onBusy, +}: AddOrganisationSheetProps) { + const { organizationTypes } = useOrganizationTypes(); + const [formData, setFormData] = useState(initialFormData); + + useEffect(() => { + if (open) return; + setFormData(initialFormData); + }, [open]); + + const { mutateAsync: createOrganisation, isLoading: creating } = useMutation( + (input: OrganizationInput) => + GraphQL(organizationCreationMutation, {}, { input }) + ); + + const { mutateAsync: addPartner, isLoading: adding } = useMutation( + (organizationId: string) => + GraphQL(AddPartners, ownerArgs, { + useCaseId, + organizationId, + }) + ); + + const isSaving = creating || adding; + + useEffect(() => { + onBusy?.(isSaving); + return () => onBusy?.(false); + }, [isSaving, onBusy]); + + const handleClose = () => { + if (isSaving) return; + onClose(); + }; + + const handleSave = async () => { + const formValidation = formData.name && formData.logo; + + if (!formValidation) { + toast('Please fill all the required fields'); + return; + } + + try { + const res = await createOrganisation(formData); + if (res.createOrganization.__typename !== 'TypeOrganization') { + toast('Unable to create organisation'); + return; + } + + await addPartner(res.createOrganization.id); + toast('Organization created successfully'); + onAdded(); + onClose(); + } catch (error) { + toast(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + return ( + { + if (!next) { + if (document.querySelector('[class*="Select-module_Popover"]')) { + return; + } + handleClose(); + } + }} + > + +
+
+ + Add New Organisation + + + Add an organisation to this Use Case. + +
+ + Close + +
+ +
+
+ + Logo{' '} + + * + + + + Upload a logo for this organisation. + +
+ { + if (!accepted[0]) return; + setFormData({ ...formData, logo: accepted[0] }); + }} + > +
+ + + Drag and drop a logo here, or click to browse. + + + + {formData.logo + ? formData.logo.name + : 'JPG, PNG or WEBP. Max 20MB.'} + +
+
+
+
+ setFormData({ ...formData, name: value })} + /> + + + setFormData({ ...formData, homepage: value }) + } + /> + { + handleFile(event.target.files?.[0]); + event.target.value = ''; + }} + /> + + )} + {showErrors && !block.src ? ( + + Upload an image or paste a URL + + ) : null} + onChange({ ...block, src: value }, false)} + onBlur={(value) => { + const keepInline = + !value.trim() && + (block.src.startsWith('data:') || block.src.startsWith('blob:')); + onChange(keepInline ? block : { ...block, src: value }, true); + }} + /> + onChange({ ...block, caption: value }, false)} + onBlur={(value) => onChange({ ...block, caption: value }, true)} + /> + + ); +} + +function ChartBlockEditor({ + block, + options, + onChange, + onBlur, +}: { + block: ChartBlock; + options: ChartOption[]; + onChange: (block: ChartBlock) => void; + onBlur: (block: ChartBlock) => void; +}) { + return ( +
+
+ +
+ ({ + label: item.name, + value: item.id, + }))} + selectedValue={ + block.chartId + ? [ + { + label: block.chartName || block.chartId, + value: block.chartId, + }, + ] + : [] + } + displaySelected + onChange={(value) => { + const selected = Array.isArray(value) ? value[0] : undefined; + const match = options.find((item) => item.id === selected?.value); + onBlur({ + ...block, + chartId: selected?.value ?? '', + chartName: selected?.label ?? '', + chartKind: match?.kind, + }); + }} + /> +
+
+ {block.chartId ? ( + + ) : ( +
+ + No chart selected yet. + +
+ )} + onChange({ ...block, caption: value })} + onBlur={(value) => onBlur({ ...block, caption: value })} + /> +
+ ); +} + +const HIGHLIGHT_SWATCHES: { tone: HighlightTone; label: string }[] = [ + { tone: 'blue', label: 'Blue' }, + { tone: 'amber', label: 'Amber' }, + { tone: 'green', label: 'Green' }, +]; + +function HighlightBlockEditor({ + block, + onChange, + onBlur, +}: { + block: HighlightBlock; + onChange: (block: HighlightBlock) => void; + onBlur: (block: HighlightBlock) => void; +}) { + return ( +
+
+ onChange({ ...block, title: value })} + onBlur={(value) => onBlur({ ...block, title: value })} + /> + onChange({ ...block, body: value })} + onBlur={(value) => onBlur({ ...block, body: value })} + /> +
+
+ {HIGHLIGHT_SWATCHES.map((swatch) => ( +
+
+ ); +} + +function LinkBlockEditor({ + block, + onChange, + onBlur, +}: { + block: LinkBlock; + onChange: (block: LinkBlock) => void; + onBlur: (block: LinkBlock) => void; +}) { + return ( +
+ +
+ onChange({ ...block, label: value })} + onBlur={(value) => onBlur({ ...block, label: value })} + /> + onChange({ ...block, url: value })} + onBlur={(value) => onBlur({ ...block, url: value })} + /> + onChange({ ...block, description: value })} + onBlur={(value) => onBlur({ ...block, description: value })} + /> +
+
+ ); +} + +function PlainField({ + name, + value, + placeholder, + size, + onChange, + onBlur, +}: { + name: string; + value: string; + placeholder: string; + size?: 'heading' | 'support' | 'caption' | 'label' | 'mono'; + onChange: (value: string) => void; + onBlur: (value: string) => void; +}) { + const sizeClass = + size === 'heading' + ? styles.plainFieldHeading + : size === 'support' + ? styles.plainFieldSupport + : size === 'caption' + ? styles.plainFieldCaption + : size === 'label' + ? styles.plainFieldLabel + : size === 'mono' + ? styles.plainFieldMono + : ''; + + return ( + onChange(event.target.value)} + onBlur={(event) => onBlur(event.target.value)} + /> + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EditLayout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EditLayout.tsx new file mode 100644 index 00000000..c7a57e63 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EditLayout.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { ReactNode, useEffect } from 'react'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import { graphql } from '@/gql'; +import { UseCaseInputPartial } from '@/gql/generated/graphql'; +import { + IconClipboardCheck, + IconLayout2, + IconShare, +} 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 { useUseCaseEditStatus } from '../context'; +import styles from '../edit.module.scss'; +import { + isUseCaseBuilderComplete, + isUseCaseConnectComplete, + useCaseWizardSummaryQuery, +} from '../usecase-summary'; +import { WizardHeader } from './WizardHeader'; + +interface LayoutProps { + children?: ReactNode; + params: { id: string }; +} + +const STEP_BY_PATH: Record = { + builder: 1, + connect: 2, + publish: 3, +}; + +const PATH_BY_STEP: Record = { + 1: 'builder', + 2: 'connect', + 3: 'publish', +}; + +const layoutList = ['builder', 'connect', 'publish']; + +const UpdateUseCaseTitleMutation = graphql(` + mutation updateUseCaseWizardTitle($data: UseCaseInputPartial!) { + updateUseCase(data: $data) { + __typename + id + title + } + } +`); + +function StepErrorSync() { + const { showErrors } = useStepperStep(); + const { setStepShowErrors } = useUseCaseEditStatus(); + + 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( + [`usecase_wizard_${routerParams.id}`], + () => + GraphQL( + useCaseWizardSummaryQuery, + { + [routerParams.entityType]: routerParams.entitySlug, + }, + { + filters: { + id: routerParams.id, + }, + } + ) + ); + + const pathItem = layoutList.find((item) => pathName.indexOf(item) >= 0); + const currentStep = pathItem ? STEP_BY_PATH[pathItem] : 1; + const useCase = summaryQuery.data?.useCases[0]; + const completionReady = summaryQuery.isFetched; + + const { + status, + setStatus, + runBeforeNavigateHandler, + builderCompleted, + setBuilderCompleted, + connectCompleted, + setConnectCompleted, + } = useUseCaseEditStatus(); + + useEffect(() => { + if (!useCase) return; + if (pathItem !== 'builder') { + setBuilderCompleted(isUseCaseBuilderComplete(useCase)); + } + if (pathItem !== 'connect') { + setConnectCompleted(isUseCaseConnectComplete(useCase)); + } + }, [useCase, pathItem, setBuilderCompleted, setConnectCompleted]); + + const { mutate: saveTitle } = useMutation( + (data: { data: UseCaseInputPartial }) => + GraphQL( + UpdateUseCaseTitleMutation, + { + [routerParams.entityType]: routerParams.entitySlug, + }, + data + ), + { + onMutate: () => setStatus('loading'), + onSuccess: () => { + void summaryQuery.refetch(); + void queryClient.invalidateQueries({ + queryKey: [`fetch_UseCaseBuilder`, params.id], + }); + setStatus('success'); + }, + onError: (error: unknown) => { + setStatus('success'); + toast( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ? error.message + : 'Unable to update title right now.' + ); + }, + } + ); + + if (!pathItem) { + return <>{children}; + } + + const goBackURL = `/dashboard/${routerParams.entityType}/${routerParams.entitySlug}/usecases`; + const stepBase = `/dashboard/${routerParams.entityType}/${routerParams.entitySlug}/usecases/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: 'Builder', + description: 'Create and structure your Use Case', + icon: IconLayout2, + isCompleted: + builderCompleted || (!completionReady && currentStep > 1), + content: stepContent(currentStep === 1, children), + }, + { + step: 2, + label: 'Connect', + description: 'Add context, datasets, and contributors', + icon: IconShare, + isCompleted: + connectCompleted || (!completionReady && currentStep > 2), + content: stepContent(currentStep === 2, children), + }, + { + step: 3, + label: 'Review & Publish', + description: 'Check readiness', + icon: IconClipboardCheck, + isCompleted: builderCompleted && connectCompleted, + content: stepContent(currentStep === 3, children), + }, + ]; + + return ( +
+ + saveTitle({ data: { id: params.id, title: nextTitle } }) + } + /> + +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx new file mode 100644 index 00000000..0f6af1a6 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx @@ -0,0 +1,565 @@ +'use client'; + +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import Image from 'next/image'; +import { Button, Icon, Spinner, Text } from 'opub-ui'; +import { createPortal } from 'react-dom'; + +import { Icons } from '@/components/icons'; +import styles from './entity-row-picker.module.scss'; + +export interface EntityRow { + id: string; + title: string; + subtitle?: string; + summary?: string; + badge?: string; + role?: string; + kind?: EntityRowVariant; + imageUrl?: string | null; + href?: string; +} + +export type EntityRowPickerHandle = { + focus: () => void; +}; + +export type EntityRowVariant = 'dataset' | 'person' | 'org'; + +interface EntityRowPickerProps { + searchPlaceholder: string; + emptyTitle: string; + emptyDescription?: string; + options: EntityRow[]; + selected: EntityRow[]; + variant?: EntityRowVariant; + hasMore?: boolean; + isLoading?: boolean; + isLoadingMore?: boolean; + onSearch?: (value: string) => void; + onLoadMore?: () => void; + onAdd: (item: EntityRow) => void; + onRemove: (id: string) => void; + roleOptionsFor?: ( + item: EntityRow + ) => Array<{ label: string; value: string }>; + onRoleChange?: (item: EntityRow, role: string) => void; +} + +function EntityAvatar({ + variant, + imageUrl, + size = 40, +}: { + variant: EntityRowVariant; + imageUrl?: string | null; + size?: number; +}) { + const square = variant === 'dataset' || variant === 'org'; + + if (imageUrl) { + return ( + + ); + } + + const icon = + variant === 'dataset' + ? Icons.dataset + : variant === 'org' + ? Icons.userGroup + : Icons.user; + + return ( + + 32 ? 18 : 16} /> + + ); +} + +function previewSummary(value?: string) { + if (!value) return ''; + return value + .replace(/<[^>]+>/g, ' ') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[*_#>`~]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function EntityCopy({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ ); +} + +function ResultSkeleton() { + return ( + <> + {Array.from({ length: 4 }).map((_, index) => ( +
+ + + + + +
+ ))} + + ); +} + +function LoadMoreSentinel({ + root, + enabled, + onLoadMore, +}: { + root: HTMLElement | null; + enabled: boolean; + onLoadMore: () => void; +}) { + const sentinelRef = useRef(null); + + useEffect(() => { + if (!enabled || !root || !sentinelRef.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) onLoadMore(); + }, + { root, rootMargin: '80px' } + ); + + observer.observe(sentinelRef.current); + return () => observer.disconnect(); + }, [enabled, root, onLoadMore]); + + return
; +} + +export const EntityRowPicker = forwardRef< + EntityRowPickerHandle, + EntityRowPickerProps +>(function EntityRowPicker( + { + searchPlaceholder, + emptyTitle, + emptyDescription, + options, + selected, + variant = 'person', + hasMore = false, + isLoading = false, + isLoadingMore = false, + onSearch, + onLoadMore, + onAdd, + onRemove, + roleOptionsFor, + onRoleChange, + }, + ref +) { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const [menuRect, setMenuRect] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + const [listEl, setListEl] = useState(null); + const [awaitingResults, setAwaitingResults] = useState(false); + const [pendingAdds, setPendingAdds] = useState([]); + const [removingIds, setRemovingIds] = useState([]); + const [openSummaryIds, setOpenSummaryIds] = useState([]); + const wrapRef = useRef(null); + const inputRef = useRef(null); + const menuRef = useRef(null); + const sawLoadingRef = useRef(false); + const isLoadingRef = useRef(isLoading); + isLoadingRef.current = isLoading; + const serverPaged = Boolean(onLoadMore); + + useImperativeHandle(ref, () => ({ + focus: () => { + setOpen(true); + inputRef.current?.focus(); + }, + })); + + useEffect(() => { + onSearch?.(query); + }, [query, onSearch]); + + useEffect(() => { + if (isLoading) { + sawLoadingRef.current = true; + return; + } + if (sawLoadingRef.current) { + sawLoadingRef.current = false; + setAwaitingResults(false); + } + }, [isLoading]); + + useEffect(() => { + if (!awaitingResults) return; + const timer = window.setTimeout(() => { + if (!isLoadingRef.current) setAwaitingResults(false); + }, 800); + return () => window.clearTimeout(timer); + }, [awaitingResults, query]); + + useEffect(() => { + if (!open) { + setMenuRect(null); + setListEl(null); + return; + } + + const updatePosition = () => { + const el = wrapRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setMenuRect({ + top: rect.bottom + 8, + left: rect.left, + width: rect.width, + }); + }; + + updatePosition(); + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + + const onPointerDown = (event: MouseEvent) => { + const target = event.target as Node; + if (wrapRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + const selectedIds = new Set(selected.map((item) => item.id)); + const visible = options.filter((item) => { + if (selectedIds.has(item.id)) return false; + if (pendingAdds.some((pending) => pending.id === item.id)) return false; + if (serverPaged) return true; + return `${item.title} ${item.subtitle ?? ''}` + .toLowerCase() + .includes(query.toLowerCase()); + }); + + useEffect(() => { + const ids = new Set(selected.map((item) => item.id)); + setPendingAdds((prev) => prev.filter((item) => !ids.has(item.id))); + setRemovingIds((prev) => prev.filter((id) => ids.has(id))); + }, [selected]); + + useEffect(() => { + if (pendingAdds.length === 0 && removingIds.length === 0) return; + const timer = window.setTimeout(() => { + const ids = new Set(selected.map((item) => item.id)); + setPendingAdds((prev) => prev.filter((item) => ids.has(item.id))); + setRemovingIds((prev) => prev.filter((id) => ids.has(id))); + }, 8000); + return () => window.clearTimeout(timer); + }, [pendingAdds, removingIds, selected]); + + useEffect(() => { + if (visible.length > 0) setAwaitingResults(false); + }, [visible.length]); + + const searchField = ( +
+ + + + { + const value = event.target.value; + setQuery(value); + setOpen(true); + if (onSearch) setAwaitingResults(value.trim().length > 0); + }} + onFocus={() => setOpen(true)} + /> +
+ ); + + const emptyMessage = query.trim() + ? 'No matches found.' + : serverPaged + ? 'No published datasets found.' + : 'Start typing to search.'; + const showLoading = + query.trim().length > 0 && + visible.length === 0 && + (isLoading || awaitingResults); + const shownSelected = [ + ...selected, + ...pendingAdds.filter((item) => !selectedIds.has(item.id)), + ]; + + const menu = + open && menuRect + ? createPortal( +
+
+ {showLoading ? ( + + ) : visible.length > 0 ? ( + <> + {visible.map((item) => ( + + ))} + {onLoadMore ? ( + + ) : null} + {isLoadingMore ? ( +
+ +
+ ) : null} + + ) : ( +
+ + {emptyMessage} + +
+ )} +
+
, + document.body + ) + : null; + + return ( +
+
{searchField}
+ {menu} + + {shownSelected.length === 0 ? ( +
+ {emptyTitle} + {emptyDescription ? ( +
+ + {emptyDescription} + +
+ ) : null} +
+ ) : ( +
+ {shownSelected.map((item) => { + const isPending = pendingAdds.some( + (pending) => pending.id === item.id && !selectedIds.has(item.id) + ); + const isRemoving = removingIds.includes(item.id); + const isBusy = isPending || isRemoving; + + const summary = previewSummary(item.summary); + const summaryOpen = openSummaryIds.includes(item.id); + + return ( +
+
+
+ +
+

+ {item.title} +

+ {variant === 'dataset' ? ( +
+ {item.badge ? ( + {item.badge} + ) : null} + {item.subtitle ? ( + {item.subtitle} + ) : null} +
+ ) : variant === 'person' ? null : item.subtitle ? ( +

{item.subtitle}

+ ) : null} +
+
+
+ {isBusy ? ( + + + + ) : ( + <> + {variant === 'dataset' ? ( + + ) : item.href ? ( + + ) : null} + {roleOptionsFor ? ( + + ) : variant === 'person' ? ( + + ) : null} + + + )} +
+
+ {variant === 'dataset' && summaryOpen && summary ? ( +
+

{summary}

+
+ ) : null} +
+ ); + })} +
+ )} +
+ ); +}); diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/WizardHeader.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/WizardHeader.tsx new file mode 100644 index 00000000..fcdf594f --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/WizardHeader.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useEffect, 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); + + useEffect(() => { + setDraftTitle(title); + }, [title]); + + const handleSave = () => { + const next = draftTitle.trim(); + if (next && next !== title) { + onTitleSave?.(next); + } + setEditing(false); + }; + + return ( +
+
+
+ + + + {editing ? ( +
+ + +
+ ) : titlePending ? ( +
+ ) : ( + <> + + {title || 'Untitled Use Case'} + + {onTitleSave ? ( + + ) : null} + + )} +
+
+ {status === 'loading' ? ( + + ) : ( + + )} + + {status === 'loading' ? 'Saving…' : 'All changes saved'} + +
+
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/entity-row-picker.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/entity-row-picker.module.scss new file mode 100644 index 00000000..fbed508f --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/entity-row-picker.module.scss @@ -0,0 +1,350 @@ +.trigger { + position: relative; + width: 100%; +} + +.searchIcon { + pointer-events: none; + position: absolute; + left: 12px; + top: 50%; + z-index: 1; + transform: translateY(-50%); + color: var(--text-subdued, #868e96); +} + +.searchInput { + width: 100%; + border: 1px solid #d8dde3; + border-radius: 8px; + background: #fff; + padding: 10px 12px 10px 36px; + font-size: 14px; + line-height: 20px; + color: var(--text-default, #1a1d21); + outline: none; + + &::placeholder { + color: #8b929a; + } + + &:focus { + border-color: #c3c8ce; + box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.04); + } +} + +.menu { + overflow: hidden; + border: 1px solid #e6e8eb; + border-radius: 12px; + background: #fff; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12); +} + +.menuList { + max-height: 320px; + overflow-y: auto; +} + +.footer { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 0; +} + +.sentinel { + height: 1px; + width: 100%; +} + +.menuItem { + display: flex; + width: 100%; + align-items: center; + gap: 12px; + border: 0; + border-bottom: 1px solid #eee; + background: #fff; + padding: 12px 16px; + text-align: left; + cursor: pointer; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: #f8f9fb; + } +} + +.avatar { + display: flex; + height: 40px; + width: 40px; + flex-shrink: 0; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 999px; + background: #eef1f4; + color: #7b838c; +} + +.avatarImage { + height: 40px; + width: 40px; + border-radius: 999px; + object-fit: cover; +} + +.avatarSquare { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 8px; + background: #eef1f4; + color: #7b838c; +} + +.avatarImageSquare { + border-radius: 8px; + object-fit: cover; +} + +.copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.title { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 600; + line-height: 20px; + color: #1a1d21; +} + +.titlePerson { + font-weight: 500; +} + +.meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.badge { + flex-shrink: 0; + border-radius: 999px; + background: var(--base-green-solid-3); + padding: 2px 8px; + color: var(--base-green-solid-11); + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +.subtitle { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + line-height: 16px; + color: #8b929a; +} + +.empty { + padding: 16px; +} + +.skeletonRow { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid #eee; + + &:last-child { + border-bottom: 0; + } +} + +.skeletonAvatar { + height: 40px; + width: 40px; + flex-shrink: 0; + border-radius: 999px; + background: #eef1f4; + animation: pulse 1.2s ease-in-out infinite; +} + +.skeletonCopy { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; +} + +.skeletonLine { + height: 12px; + border-radius: 6px; + background: #eef1f4; + animation: pulse 1.2s ease-in-out infinite; +} + +.skeletonTitle { + width: 58%; + height: 14px; +} + +.skeletonSubtitle { + width: 34%; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.selectedCards { + display: flex; + flex-direction: column; + gap: 12px; +} + +.card { + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid #e6e8eb; + border-radius: 12px; + background: #fff; + padding: 10px 12px; +} + +.roleSelect { + width: 148px; + height: 32px; + flex-shrink: 0; + border: 1px solid #d8dde3; + border-radius: 8px; + background: #fff; + padding: 0 8px; + color: #1a1d21; + font-size: 13px; + line-height: 16px; + outline: none; + cursor: pointer; +} + +.roleInput { + width: 160px; + height: 32px; + flex-shrink: 0; + border: 1px solid #d8dde3; + border-radius: 8px; + background: var(--base-gray-slate-solid-2); + padding: 0 10px; + color: #1a1d21; + font-size: 12px; + line-height: 16px; + outline: none; + cursor: not-allowed; + + &::placeholder { + color: #8b929a; + } +} + +.selectedHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.summary { + min-width: 0; + border-radius: 8px; + background: #f3f4f6; + padding: 12px 14px; +} + +.summaryText { + display: -webkit-box; + margin: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + color: #4b5563; + font-size: 13px; + line-height: 1.45; + overflow-wrap: break-word; +} + +.selectedRowBusy { + opacity: 0.7; + pointer-events: none; +} + +.selectedSpinner { + display: flex; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; +} + +.deleteButton { + display: flex; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--icon-default); + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--base-red-solid-3); + color: var(--icon-critical); + } + + &:hover :global(svg), + &:focus-visible :global(svg) { + color: var(--icon-critical) !important; + stroke: var(--icon-critical) !important; + } +} + +.placeholder { + border-radius: 12px; + background: #f6f7f8; + padding: 24px 16px; + text-align: center; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/content-document.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/content-document.ts new file mode 100644 index 00000000..86066dfb --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/content-document.ts @@ -0,0 +1,211 @@ +export type ContentBlockType = + | 'text' + | 'image' + | 'chart' + | 'highlight' + | 'link'; + +export type TextBlock = { id: string; type: 'text'; html: string }; +export type ImageBlock = { + id: string; + type: 'image'; + src: string; + caption?: string; +}; +export type ChartBlock = { + id: string; + type: 'chart'; + chartId: string; + chartName?: string; + chartKind?: string; + caption?: string; +}; +export const HIGHLIGHT_TONES = ['blue', 'amber', 'green'] as const; +export type HighlightTone = (typeof HIGHLIGHT_TONES)[number]; + +export type HighlightBlock = { + id: string; + type: 'highlight'; + title: string; + body: string; + tone: HighlightTone; +}; +export type LinkBlock = { + id: string; + type: 'link'; + label: string; + url: string; + description?: string; +}; + +export type ContentBlock = + | TextBlock + | ImageBlock + | ChartBlock + | HighlightBlock + | LinkBlock; + +export type UseCaseContentDocument = { + version: 1; + subtitle: string; + blocks: ContentBlock[]; +}; + +const EMPTY_DOCUMENT: UseCaseContentDocument = { + version: 1, + subtitle: '', + blocks: [], +}; + +export function createBlockId() { + return `block_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function isLegacyHtmlSummary(summary?: string | null): boolean { + if (!summary?.trim()) return false; + try { + const parsed = JSON.parse(summary) as { version?: number }; + return parsed?.version !== 1; + } catch { + return true; + } +} + +export function parseUseCaseContent( + summary?: string | null +): UseCaseContentDocument { + if (!summary?.trim()) return { ...EMPTY_DOCUMENT, blocks: [] }; + + try { + const parsed = JSON.parse(summary) as Partial; + if (parsed?.version === 1 && Array.isArray(parsed.blocks)) { + return { + version: 1, + subtitle: typeof parsed.subtitle === 'string' ? parsed.subtitle : '', + blocks: parsed.blocks + .map(normalizeBlock) + .filter((block): block is ContentBlock => block !== null), + }; + } + } catch { + // Legacy HTML summary — keep it as a text block so existing drafts are not lost. + } + + return { + version: 1, + subtitle: '', + blocks: [ + { + id: createBlockId(), + type: 'text', + html: summary, + }, + ], + }; +} + +export function serializeUseCaseContent(doc: UseCaseContentDocument): string { + return JSON.stringify({ + version: 1, + subtitle: doc.subtitle ?? '', + blocks: doc.blocks, + }); +} + +export function isRichTextEmpty(html?: string | null): boolean { + if (!html) return true; + return html.replace(/<(.|\n)*?>/g, '').replace(/ /g, ' ').trim().length === 0; +} + +export function isBlockEmpty(block: ContentBlock): boolean { + switch (block.type) { + case 'text': + return isRichTextEmpty(block.html); + case 'image': + return !block.src?.trim() || block.src.startsWith('blob:'); + case 'chart': + return !block.chartId?.trim(); + case 'highlight': + return !block.title.trim() && !block.body.trim(); + case 'link': + return !block.url.trim(); + default: + return true; + } +} + +export function hasContentBlocks(doc: UseCaseContentDocument): boolean { + return doc.blocks.some((block) => !isBlockEmpty(block)); +} + +export function extractEmbedUrl(value: string): string { + const trimmed = value.trim(); + const srcMatch = trimmed.match(/src=["']([^"']+)["']/i); + if (srcMatch?.[1]) return srcMatch[1]; + return trimmed; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function isHighlightTone(value: unknown): value is HighlightTone { + return ( + value === 'blue' || value === 'amber' || value === 'green' + ); +} + +function normalizeBlock(value: unknown): ContentBlock | null { + if (!value || typeof value !== 'object') return null; + const block = value as Record; + if (typeof block.id !== 'string' || typeof block.type !== 'string') { + return null; + } + + switch (block.type) { + case 'text': + return { id: block.id, type: 'text', html: readString(block.html) }; + case 'image': + return { + id: block.id, + type: 'image', + src: readString(block.src), + caption: readString(block.caption), + }; + case 'chart': + return { + id: block.id, + type: 'chart', + chartId: readString(block.chartId), + chartName: readString(block.chartName), + chartKind: readString(block.chartKind) || undefined, + caption: readString(block.caption), + }; + case 'highlight': + return { + id: block.id, + type: 'highlight', + title: readString(block.title), + body: readString(block.body), + tone: isHighlightTone(block.tone) ? block.tone : 'blue', + }; + case 'link': + return { + id: block.id, + type: 'link', + label: readString(block.label), + url: readString(block.url), + description: readString(block.description), + }; + case 'embed': + return { + id: block.id, + type: 'link', + label: '', + url: extractEmbedUrl(readString(block.url)), + description: '', + }; + default: + return null; + } +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/context.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/context.tsx index 864e36c8..af36ce68 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/context.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/context.tsx @@ -1,27 +1,78 @@ '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 UseCaseEditStatusContextValue { status: StatusType; setStatus: (status: StatusType) => void; -} | null>(null); + registerBeforeNavigateHandler: (handler: BeforeNavigateHandler) => void; + runBeforeNavigateHandler: () => Promise; + builderCompleted: boolean; + setBuilderCompleted: (completed: boolean) => void; + connectCompleted: boolean; + setConnectCompleted: (completed: boolean) => void; + stepShowErrors: boolean; + setStepShowErrors: (show: boolean) => void; +} + +const UseCaseEditStatusContext = + createContext(null); -export const EditStatusProvider = ({ children }: { children: React.ReactNode }) => { +export const UseCaseEditStatusProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { const [status, setStatus] = useState('success'); + const [builderCompleted, setBuilderCompleted] = useState(false); + const [connectCompleted, setConnectCompleted] = 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 = UseCaseEditStatusProvider; + +export const useUseCaseEditStatus = () => { + const context = useContext(UseCaseEditStatusContext); if (!context) { - throw new Error('useEditStatus must be used within EditStatusProvider'); + throw new Error( + 'useUseCaseEditStatus must be used within UseCaseEditStatusProvider' + ); } return context; }; + +export const useEditStatus = () => { + const { status, setStatus } = useUseCaseEditStatus(); + return { status, setStatus }; +}; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/edit.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/edit.module.scss new file mode 100644 index 00000000..34e417eb --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/edit.module.scss @@ -0,0 +1,314 @@ +.useCaseStepper { + padding-left: var(--space-6); + padding-right: var(--space-6); + padding-top: var(--space-6); + --action-primary-basic-default: var(--blue-primary-color); + --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; + } +} + +:global([class*='Select-module_PopoverWrapper']), +:global([class*='Combobox-module_PopoverWrapper']) { + z-index: calc(var(--z-max, 99999) + 1) !important; + pointer-events: auto; +} + +.menu { + display: flex; + width: 148px; + flex-direction: column; + padding: 4px; +} + +.menuItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + margin: 0; + padding: 8px; + border: 0; + border-radius: 4px; + background: transparent; + box-shadow: none; + color: var(--text-default); + font-family: inherit; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.25; + text-align: left; + cursor: pointer; + appearance: none; + + svg { + flex-shrink: 0; + color: var(--text-subdued); + } + + &:hover { + background: var(--base-gray-slate-solid-3); + } +} + +.canvas { + overflow: hidden; + border: 1px solid var(--base-gray-slate-solid-6); + border-radius: 12px; + background: var(--surface-default); +} + +.canvasBody { + display: flex; + flex-direction: column; + gap: 20px; + padding: 24px; +} + +.canvasEmpty { + display: flex; + min-height: 220px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 56px 24px; + text-align: center; +} + +.addContentFooter { + border-top: 1px solid var(--base-gray-slate-solid-6); + padding: 16px 24px; +} + +.captionText { + margin: 6px 0 0; + color: var(--text-subdued); + font-size: 0.75rem; + font-weight: 400; + line-height: 1.4; +} + +.plainField { + width: 100%; + margin: 0; + padding: 0; + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + box-shadow: none; + color: var(--text-default); + font-family: inherit; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + outline: none; + appearance: none; + + &::placeholder { + color: color-mix(in srgb, var(--text-subdued) 50%, transparent); + } + + &:hover { + background: color-mix( + in srgb, + var(--base-gray-slate-solid-3) 40%, + transparent + ); + } + + &:focus { + background: color-mix( + in srgb, + var(--base-gray-slate-solid-3) 55%, + transparent + ); + } + + &.plainFieldHeading { + color: var(--blue-primary-color); + font-size: 1rem; + font-weight: 600; + line-height: 1.5; + } + + &.plainFieldSupport { + color: var(--text-subdued); + font-size: 0.875rem; + line-height: 1.5; + } + + &.plainFieldCaption { + margin-top: 6px; + color: var(--text-subdued); + font-size: 0.75rem; + font-weight: 400; + line-height: 1.4; + } + + &.plainFieldLabel { + color: var(--blue-primary-color); + font-size: 0.875rem; + font-weight: 600; + line-height: 1.4; + } + + &.plainFieldMono { + color: var(--text-subdued); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + line-height: 1.4; + } +} + +.highlightRow { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.highlightCard { + flex: 1; + min-width: 0; + border-radius: 12px; + padding: 14px 16px; +} + +.highlightSwatches { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 10px; +} + +.highlightSwatch { + width: 16px; + height: 16px; + margin: 0; + padding: 0; + border: 1px solid var(--base-gray-slate-solid-7); + border-radius: 999px; + cursor: pointer; + appearance: none; +} + +.highlightSwatchSelected { + box-shadow: + 0 0 0 2px var(--surface-default, #fff), + 0 0 0 3px var(--blue-primary-color); +} + +.highlightTone0 { + background: color-mix(in srgb, var(--blue-primary-color) 8%, white); +} + +.highlightTone1 { + background: var(--base-amber-solid-3); +} + +.highlightTone2 { + background: var(--base-green-solid-3); +} + +.block { + position: relative; + margin-right: -12px; + margin-left: -12px; + border: 1px solid transparent; + border-radius: 8px; + padding: 8px 12px; + transition: + border-color 150ms, + background-color 150ms; + + &:hover { + border-color: var(--base-gray-slate-solid-6); + } + + &:hover .toolbar { + opacity: 1; + pointer-events: auto; + } +} + +.blockActive { + border-color: color-mix(in srgb, var(--blue-primary-color) 40%, transparent); + background: color-mix(in srgb, var(--blue-primary-color) 4%, transparent); +} + +.toolbar { + position: absolute; + top: -16px; + right: 8px; + z-index: 10; + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--base-gray-slate-solid-6); + border-radius: 6px; + background: var(--surface-default, #fff); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + opacity: 0; + pointer-events: none; +} + +.toolbarVisible { + opacity: 1; + pointer-events: auto; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/layout.tsx index 973d18e2..692e59b1 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/layout.tsx @@ -1,203 +1,7 @@ -'use client'; - -import { useParams, usePathname, useRouter } from 'next/navigation'; -import { graphql } from '@/gql'; -import { UseCaseInputPartial } 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 UpdateUseCaseTitleMutation = graphql(` - mutation updateUseCaseTitle($data: UseCaseInputPartial!) { - updateUseCase(data: $data) { - __typename - id - title - } - } -`); - -const FetchUseCaseTitle = graphql(` - query UseCaseTitle($filters: UseCaseFilter) { - useCases(filters: $filters) { - id - title - } - } -`); - -const TabsAndChildren = ({ children }: { children: React.ReactNode }) => { - const router = useRouter(); - const pathName = usePathname(); - const params = useParams<{ - entityType: string; - entitySlug: string; - id: string; - }>(); - const USECASE_TITLE_SUCCESS_TOAST_ID = 'usecase-title-save-success'; - const USECASE_TITLE_ERROR_TOAST_ID = 'usecase-title-save-error'; - const queryClient = useQueryClient(); - const getErrorMessage = (error: unknown, fallback: string) => - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message.trim() - ? error.message.trim() - : fallback; - - const layoutList = [ - 'details', - 'contributors', - 'assign', - 'dashboards', - 'publish', - ]; - - const pathItem = layoutList.find(function (v) { - return pathName.indexOf(v) >= 0; - }); - - const UseCaseData = useQuery( - [`fetch_UseCaseData`], - () => - GraphQL( - FetchUseCaseTitle, - { - [params.entityType]: params.entitySlug, - }, - { - filters: { - id: params.id, - }, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - - const { mutate, isLoading: editMutationLoading } = useMutation( - (data: { data: UseCaseInputPartial }) => - GraphQL(UpdateUseCaseTitleMutation, { - [params.entityType]: params.entitySlug, - }, data), - { - onSuccess: () => { - toast('Use case updated successfully', { - id: USECASE_TITLE_SUCCESS_TOAST_ID, - }); - // Optionally, reset form or perform other actions - UseCaseData.refetch(); - queryClient.invalidateQueries({ - queryKey: [ - `fetch_UsecaseDetails`, - params.id, - params.entityType, - params.entitySlug, - ], - }); - }, - onError: (error: unknown) => { - toast( - `Error: ${getErrorMessage(error, 'Unable to update use case title right now. Please try again.')}`, - { id: USECASE_TITLE_ERROR_TOAST_ID } - ); - }, - } - ); - - const links = [ - { - label: 'Use Case Details', - url: `/dashboard/${params.entityType}/${params.entitySlug}/usecases/edit/${params.id}/details`, - selected: pathItem === 'details', - }, - { - label: 'Datasets', - url: `/dashboard/${params.entityType}/${params.entitySlug}/usecases/edit/${params.id}/assign`, - selected: pathItem === 'assign', - }, - { - label: 'Dashboards', - url: `/dashboard/${params.entityType}/${params.entitySlug}/usecases/edit/${params.id}/dashboards`, - selected: pathItem === 'dashboards', - }, - { - label: 'Contributors', - url: `/dashboard/${params.entityType}/${params.entitySlug}/usecases/edit/${params.id}/contributors`, - selected: pathItem === 'contributors', - }, - { - label: 'Publish', - url: `/dashboard/${params.entityType}/${params.entitySlug}/usecases/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 || 'Use Case Details'; - - const { status, setStatus } = useEditStatus(); - - 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 EditUseCase = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); -}; - -export default EditUseCase; +export default function EditUseCaseLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/usecase-summary.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/usecase-summary.ts new file mode 100644 index 00000000..86aa739a --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/usecase-summary.ts @@ -0,0 +1,60 @@ +import { graphql } from '@/gql'; + +import { + hasContentBlocks, + parseUseCaseContent, +} from './content-document'; + +export const useCaseWizardSummaryQuery = graphql(` + query UseCaseWizardSummary($filters: UseCaseFilter) { + useCases(filters: $filters) { + id + title + summary + slug + logo { + name + path + url + } + sectors { + id + } + sdgs { + id + } + } + } +`); + +export type UseCaseEditStep = 'builder' | 'connect' | 'publish'; + +export function isUseCaseBuilderComplete(useCase: { + title?: string | null; + logo?: unknown; +}): boolean { + return Boolean(useCase.title?.trim()) && useCase.logo != null; +} + +export function isUseCaseConnectComplete(useCase: { + sectors?: unknown[] | null; + sdgs?: unknown[] | null; +}): boolean { + return (useCase.sectors?.length ?? 0) > 0 && (useCase.sdgs?.length ?? 0) > 0; +} + +export function firstIncompleteUseCaseEditStep(useCase: { + title?: string | null; + summary?: string | null; + logo?: unknown; + sectors?: unknown[] | null; + sdgs?: unknown[] | null; +}): UseCaseEditStep { + if (!isUseCaseBuilderComplete(useCase)) return 'builder'; + if (!isUseCaseConnectComplete(useCase)) return 'connect'; + return 'publish'; +} + +export function useCaseHasContent(summary?: string | null): boolean { + return hasContentBlocks(parseUseCaseContent(summary)); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx index 5a5285c0..1ce91813 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx @@ -48,7 +48,7 @@ const deleteUseCase = graphql(` } `); -const AddUseCase = graphql(` +export const AddUseCase = graphql(` mutation Addusecase { addUseCase { __typename @@ -143,7 +143,7 @@ export default function DatasetPage() { const created = response.addUseCase; const createdId = 'id' in created ? created.id : undefined; router.push( - `/dashboard/${entityType}/${entitySlug}/usecases/edit/${createdId}/details` + `/dashboard/${entityType}/${entitySlug}/usecases/edit/${createdId}/builder` ); AllUseCases.refetch(); } @@ -206,7 +206,7 @@ export default function DatasetPage() { {row.original.title} diff --git a/hooks/useOrganizationTypes.ts b/hooks/useOrganizationTypes.ts index 6ba90b8c..0b45e650 100644 --- a/hooks/useOrganizationTypes.ts +++ b/hooks/useOrganizationTypes.ts @@ -1,25 +1,39 @@ import { ApiOrganizationOrganizationTypesEnum } from '@/gql/generated/graphql'; +const ORGANIZATION_TYPES: Array<{ + label: string; + value: ApiOrganizationOrganizationTypesEnum; +}> = [ + { label: 'State Government', value: ApiOrganizationOrganizationTypesEnum.StateGovernment }, + { label: 'Union Territory Government', value: ApiOrganizationOrganizationTypesEnum.UnionTerritoryGovernment }, + { label: 'Urban Local Body', value: ApiOrganizationOrganizationTypesEnum.UrbanLocalBody }, + { label: 'Academic Institution', value: ApiOrganizationOrganizationTypesEnum.AcademicInstitution }, + { label: 'Central Government', value: ApiOrganizationOrganizationTypesEnum.CentralGovernment }, + { label: 'Citizens Group', value: ApiOrganizationOrganizationTypesEnum.CitizensGroup }, + { label: 'Civil Society Organisation', value: ApiOrganizationOrganizationTypesEnum.CivilSocietyOrganisation }, + { label: 'Industry Body', value: ApiOrganizationOrganizationTypesEnum.IndustryBody }, + { label: 'Media Organisation', value: ApiOrganizationOrganizationTypesEnum.MediaOrganisation }, + { label: 'Open Data Technology Community', value: ApiOrganizationOrganizationTypesEnum.OpenDataTechnologyCommunity }, + { label: 'Private Company', value: ApiOrganizationOrganizationTypesEnum.PrivateCompany }, + { label: 'Public Sector Company', value: ApiOrganizationOrganizationTypesEnum.PublicSectorCompany }, + { label: 'Others', value: ApiOrganizationOrganizationTypesEnum.Others }, + { label: 'Startup', value: ApiOrganizationOrganizationTypesEnum.Startup }, + { label: 'Government', value: ApiOrganizationOrganizationTypesEnum.Government }, + { label: 'Corporations', value: ApiOrganizationOrganizationTypesEnum.Corporations }, + { label: 'NGO', value: ApiOrganizationOrganizationTypesEnum.Ngo }, +]; + export const useOrganizationTypes = () => { - const organizationTypes = [ - { label: 'State Government', value: ApiOrganizationOrganizationTypesEnum.StateGovernment }, - { label: 'Union Territory Government', value: ApiOrganizationOrganizationTypesEnum.UnionTerritoryGovernment }, - { label: 'Urban Local Body', value: ApiOrganizationOrganizationTypesEnum.UrbanLocalBody }, - { label: 'Academic Institution', value: ApiOrganizationOrganizationTypesEnum.AcademicInstitution }, - { label: 'Central Government', value: ApiOrganizationOrganizationTypesEnum.CentralGovernment }, - { label: 'Citizens Group', value: ApiOrganizationOrganizationTypesEnum.CitizensGroup }, - { label: 'Civil Society Organisation', value: ApiOrganizationOrganizationTypesEnum.CivilSocietyOrganisation }, - { label: 'Industry Body', value: ApiOrganizationOrganizationTypesEnum.IndustryBody }, - { label: 'Media Organisation', value: ApiOrganizationOrganizationTypesEnum.MediaOrganisation }, - { label: 'Open Data Technology Community', value: ApiOrganizationOrganizationTypesEnum.OpenDataTechnologyCommunity }, - { label: 'Private Company', value: ApiOrganizationOrganizationTypesEnum.PrivateCompany }, - { label: 'Public Sector Company', value: ApiOrganizationOrganizationTypesEnum.PublicSectorCompany }, - { label: 'Others', value: ApiOrganizationOrganizationTypesEnum.Others }, - { label: 'Startup', value: ApiOrganizationOrganizationTypesEnum.Startup }, - { label: 'Government', value: ApiOrganizationOrganizationTypesEnum.Government }, - { label: 'Corporations', value: ApiOrganizationOrganizationTypesEnum.Corporations }, - { label: 'NGO', value: ApiOrganizationOrganizationTypesEnum.Ngo } - ]; + return { organizationTypes: ORGANIZATION_TYPES }; +}; - return { organizationTypes }; -}; \ No newline at end of file +export function organizationTypeLabel( + value?: string | string[] | null +): string | undefined { + if (!value) return undefined; + const values = Array.isArray(value) ? value : [value]; + const labels = values + .map((item) => ORGANIZATION_TYPES.find((type) => type.value === item)?.label) + .filter((label): label is string => Boolean(label)); + return labels.length > 0 ? labels.join(' · ') : undefined; +} diff --git a/styles/tokens/_variables.css b/styles/tokens/_variables.css index 532b71cc..72506d8b 100644 --- a/styles/tokens/_variables.css +++ b/styles/tokens/_variables.css @@ -165,7 +165,7 @@ --action-secondary-interactive-hovered: var(--base-blue-solid-4); --action-secondary-interactive-depressed: var(--base-blue-solid-6); --action-primary-success-depressed: var(--base-green-solid-12); - --action-secondary-neutral-default: var(--base-gray-slate-solid-1); + --action-secondary-neutral-default: var(--base-pure-white); --action-secondary-success-default: var(--base-green-solid-3); --action-secondary-interactive-default: var(--base-blue-solid-3); --action-secondary-neutral-hovered: var(--base-gray-slate-solid-2);