+
+
+ Review & Publish
+
+
+
+ Check your information before making this content available
+ publicly.
- :
-
- Please check all the UseCase details below before publishing
+
+
+
+
+
+
+ {ready ? 'Ready to publish' : 'Needs attention'}
-
- {UseCaseData.isLoading || mutationLoading ? (
-
-
-
+
router.push(builderEdit)}
+ />
+ router.push(connectEdit)}
+ />
+
+
+
+ router.push(`${stepBase}/builder#basic-information`)
+ )}
+ >
+
+ {useCase?.logo ? (
+
) : (
- <>
- {Summary.map((item, index) => (
-
-
-
-
-
- {item.name}
-
- {item.error !== '' && (
-
-
-
- {item.error}
-
-
- )}
-
-
-
-
- {item.name === 'Assign' ? (
-
- ) : item.name === 'Details' ? (
-
- ) : item.name === 'Dashboards' ? (
-
- ) : (
-
- )}
-
-
-
-
- ))}
-
- >
+
No thumbnail uploaded
)}
+
+ {useCase?.title || '—'}
+
+
+ {document.subtitle || '—'}
+
+
+
+
+
+ router.push(`${stepBase}/builder#content`)
+ )}
+ >
+ {hasContent ? (
+
+ ) : (
+ No content added.
+ )}
+
+
+
+ router.push(`${stepBase}/connect#classification`)
+ )}
+ >
+
+ }>
+ ({
+ id: item.id,
+ label: item.value ?? '',
+ }))}
+ />
+
+ }>
+ ({
+ id: item.id,
+ label: sdgLabel(item),
+ }))}
+ />
+
+ }>
+ ({
+ id: item.id,
+ label: item.name ?? '',
+ }))}
+ />
+
+ }>
+ ({
+ id: item.id,
+ label: item.name ?? '',
+ }))}
+ />
+
+
+
+
+
+ router.push(`${stepBase}/connect#datasets`)
+ )}
+ >
+
+ }>
+ ({
+ id: item.id,
+ label: item.title ?? '',
+ }))}
+ />
+
+ }>
+ ({
+ id: item.id,
+ label: item.fullName,
+ }))}
+ />
+
+ }>
+ ({
+ id: item.id,
+ label: item.name,
+ }))}
+ />
+
+ }>
+ ({
+ id: item.id,
+ label: item.name,
+ }))}
+ />
+
+
+
+
+
+
+
+
+ Open a full preview of this Use Case 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]/usecases/edit/components/AddDatasetSheet.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddDatasetSheet.tsx
new file mode 100644
index 00000000..858c00ce
--- /dev/null
+++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddDatasetSheet.tsx
@@ -0,0 +1,786 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { DatasetEditStatusProvider } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/context';
+import { PublicPlatformImport } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/PublicPlatformImport';
+import { createResourceFilesDoc } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/query';
+import {
+ RESOURCE_FILE_ACCEPT,
+ RESOURCE_FILE_TYPES,
+} from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone';
+import { graphql } from '@/gql';
+import {
+ DatasetAccessType,
+ DatasetLicense,
+ DatasetType,
+} from '@/gql/generated/graphql';
+import { IconFileSpreadsheet, IconWorld, IconX } from '@tabler/icons-react';
+import {
+ Button,
+ Combobox,
+ DropZone,
+ FileCard,
+ Icon,
+ IconButton,
+ RadioGroup,
+ RadioItem,
+ SectionCard,
+ Select,
+ Sheet,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+ Text,
+ TextField,
+ toast,
+} from 'opub-ui';
+
+import { GraphQL } from '@/lib/api';
+import { Icons } from '@/components/icons';
+
+const MAX_RESOURCE_BYTES = 50 * 1024 * 1024;
+
+const LICENSE_OPTIONS: Array<{ label: string; value: DatasetLicense }> = [
+ {
+ label: 'Government Open Data License',
+ value: DatasetLicense.GovernmentOpenDataLicense,
+ },
+ {
+ label: 'CC BY 4.0 (Attribution)',
+ value: DatasetLicense.CcBy_4_0Attribution,
+ },
+ {
+ label: 'CC BY-SA 4.0 (Attribution-ShareAlike)',
+ value: DatasetLicense.CcBySa_4_0AttributionShareAlike,
+ },
+ {
+ label: 'Open Data Commons By Attribution',
+ value: DatasetLicense.OpenDataCommonsByAttribution,
+ },
+ {
+ label: 'Open Database License',
+ value: DatasetLicense.OpenDatabaseLicense,
+ },
+];
+
+const updateDatasetTitleMutationDoc = graphql(`
+ mutation SaveTitle($updateDatasetInput: UpdateDatasetInput!) {
+ updateDataset(updateDatasetInput: $updateDatasetInput) {
+ __typename
+ ... on TypeDataset {
+ id
+ title
+ created
+ }
+ ... on OperationInfo {
+ messages {
+ kind
+ message
+ }
+ }
+ }
+ }
+`);
+
+const updateMetadataMutationDoc = graphql(`
+ mutation SaveMetadata($UpdateMetadataInput: UpdateMetadataInput!) {
+ addUpdateDatasetMetadata(updateMetadataInput: $UpdateMetadataInput) {
+ success
+ errors {
+ fieldErrors {
+ field
+ messages
+ }
+ nonFieldErrors
+ }
+ data {
+ id
+ description
+ title
+ tags {
+ id
+ value
+ }
+ sectors {
+ id
+ name
+ }
+ geographies {
+ id
+ name
+ code
+ type
+ }
+ license
+ accessType
+ metadata {
+ metadataItem {
+ id
+ label
+ dataType
+ }
+ id
+ value
+ }
+ }
+ }
+ }
+`);
+
+const createDatasetMutation = graphql(`
+ mutation UseCaseWizardCreateDataset($createInput: CreateDatasetInput) {
+ addDataset(createInput: $createInput) {
+ success
+ errors {
+ fieldErrors {
+ messages
+ }
+ }
+ data {
+ id
+ }
+ }
+ }
+`);
+
+const assignUseCaseDatasetsDoc = graphql(`
+ mutation assignUseCaseWizardDatasets(
+ $useCaseId: String!
+ $datasetIds: [UUID!]!
+ ) {
+ updateUsecaseDatasets(useCaseId: $useCaseId, datasetIds: $datasetIds) {
+ ... on TypeUseCase {
+ id
+ datasets {
+ id
+ title
+ }
+ }
+ }
+ }
+`);
+
+const deleteDatasetMutationDoc = graphql(`
+ mutation deleteDatasetMutation($datasetId: UUID!) {
+ deleteDataset(datasetId: $datasetId)
+ }
+`);
+
+interface DatasetSectorOption {
+ label: string;
+ value: string;
+}
+
+interface DatasetDraft {
+ name: string;
+ description: string;
+ sectors: DatasetSectorOption[];
+ accessType: DatasetAccessType | '';
+ license: DatasetLicense | '';
+}
+
+interface FieldErrors {
+ name?: string;
+ description?: string;
+ sector?: string;
+ accessType?: string;
+ license?: string;
+}
+
+type UploadTab = 'upload' | 'platform';
+
+const emptyDraft: DatasetDraft = {
+ name: '',
+ description: '',
+ sectors: [],
+ accessType: '',
+ license: '',
+};
+
+export interface AddDatasetSheetProps {
+ open: boolean;
+ onClose: () => void;
+ useCaseId?: string;
+ ownerArgs: Record
;
+ sectors: DatasetSectorOption[];
+ connectedDatasetIds: string[];
+ onAdded: () => void;
+ onBusy?: (busy: boolean) => void;
+ onConnect?: (datasetId: string) => Promise;
+ intro?: string;
+ successMessage?: string;
+ descriptionPlaceholder?: string;
+}
+
+function isLicense(value: string): value is DatasetLicense {
+ return LICENSE_OPTIONS.some((option) => option.value === value);
+}
+
+function isAccess(value: string): value is DatasetAccessType {
+ return (
+ value === DatasetAccessType.Public || value === DatasetAccessType.Restricted
+ );
+}
+
+function isUploadTab(value: string): value is UploadTab {
+ return value === 'upload' || value === 'platform';
+}
+
+function selectMenuIsOpen(): boolean {
+ return Array.from(
+ document.querySelectorAll(
+ '[class*="Select-module_Popover__"], [class*="Combobox-module_Popover__"]'
+ )
+ ).some((node) => node instanceof HTMLElement && !node.hidden);
+}
+
+function selectedSectors(
+ value: DatasetSectorOption[] | string
+): DatasetSectorOption[] {
+ if (!Array.isArray(value)) return [];
+ return value.filter(
+ (item): item is DatasetSectorOption =>
+ typeof item === 'object' &&
+ item !== null &&
+ typeof item.label === 'string' &&
+ typeof item.value === 'string'
+ );
+}
+
+function fieldErrors(form: DatasetDraft): FieldErrors {
+ const errors: FieldErrors = {};
+ if (!form.name.trim()) errors.name = 'Dataset name is required';
+ if (!form.description.trim()) errors.description = 'Description is required';
+ if (form.sectors.length === 0) {
+ errors.sector = 'Sector is required';
+ }
+ if (!isAccess(form.accessType)) {
+ errors.accessType = 'Access type is required';
+ }
+ if (!isLicense(form.license)) errors.license = 'License is required';
+ return errors;
+}
+
+function hasFieldErrors(errors: FieldErrors): boolean {
+ return Object.values(errors).some((message) => Boolean(message));
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes}B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
+}
+
+function fileKey(files: File[]): string {
+ return files
+ .map((file) => `${file.name}:${file.size}:${file.lastModified}`)
+ .join('|');
+}
+
+function getErrorMessage(error: unknown, fallback: string): string {
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'message' in error &&
+ typeof error.message === 'string' &&
+ error.message.trim()
+ ) {
+ return error.message.trim();
+ }
+ return fallback;
+}
+
+function metadataErrorMessage(
+ errors:
+ | {
+ fieldErrors?: Array<{ messages: Array }> | null;
+ nonFieldErrors?: Array | null;
+ }
+ | null
+ | undefined
+): string {
+ return (
+ errors?.fieldErrors?.[0]?.messages[0] ??
+ errors?.nonFieldErrors?.[0] ??
+ 'Unable to save dataset details.'
+ );
+}
+
+export function AddDatasetSheet({
+ open,
+ onClose,
+ useCaseId,
+ ownerArgs,
+ sectors,
+ connectedDatasetIds,
+ onAdded,
+ onBusy,
+ onConnect,
+ intro = 'Create the essential dataset information and connect it to this Use Case.',
+ successMessage = 'Dataset created and added to this Use Case',
+ descriptionPlaceholder = 'Briefly describe what this dataset contains and how it relates to this Use Case.',
+}: AddDatasetSheetProps): React.ReactElement {
+ const [form, setForm] = useState(emptyDraft);
+ const [files, setFiles] = useState([]);
+ const [tab, setTab] = useState('upload');
+ const [showErrors, setShowErrors] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const createdIdRef = useRef(null);
+ const connectedRef = useRef(false);
+ const uploadedKeyRef = useRef('');
+ const [wasOpen, setWasOpen] = useState(open);
+ if (open !== wasOpen) {
+ setWasOpen(open);
+ if (!open) {
+ setForm(emptyDraft);
+ setFiles([]);
+ setTab('upload');
+ setShowErrors(false);
+ }
+ }
+
+ useEffect(() => {
+ if (open) return;
+ uploadedKeyRef.current = '';
+ connectedRef.current = false;
+ }, [open]);
+
+ useEffect(() => {
+ onBusy?.(saving);
+ return () => onBusy?.(false);
+ }, [onBusy, saving]);
+
+ const errors = showErrors ? fieldErrors(form) : {};
+
+ const discardUnassignedDraft = async (): Promise => {
+ const datasetId = createdIdRef.current;
+ createdIdRef.current = null;
+ uploadedKeyRef.current = '';
+ if (!datasetId || connectedRef.current) return;
+ try {
+ await GraphQL(deleteDatasetMutationDoc, ownerArgs, { datasetId });
+ } catch (error: unknown) {
+ toast(
+ getErrorMessage(
+ error,
+ 'The dataset was created but could not be removed.'
+ )
+ );
+ }
+ };
+
+ const handleClose = (): void => {
+ if (saving) return;
+ if (!connectedRef.current) {
+ void discardUnassignedDraft();
+ }
+ onClose();
+ };
+
+ const handleDrop = (_dropped: File[], accepted: File[]): void => {
+ const incoming = accepted.length > 0 ? accepted : _dropped;
+ if (incoming.length === 0) return;
+
+ const allowed = new Set(RESOURCE_FILE_TYPES);
+ const oversized = incoming.filter((file) => file.size > MAX_RESOURCE_BYTES);
+ const unsupported = incoming.filter((file) => {
+ const extension = file.name.split('.').pop()?.toUpperCase() ?? '';
+ return !allowed.has(extension);
+ });
+ if (oversized.length > 0) {
+ toast('Maximum file size limit: 50MB');
+ }
+ if (unsupported.length > 0) {
+ toast('That file type is not supported.');
+ }
+
+ const next = incoming.filter((file) => {
+ const extension = file.name.split('.').pop()?.toUpperCase() ?? '';
+ return file.size <= MAX_RESOURCE_BYTES && allowed.has(extension);
+ });
+ if (next.length === 0) return;
+
+ setFiles((current) => {
+ const seen = new Set(current.map((file) => fileKey([file])));
+ const added = next.filter((file) => !seen.has(fileKey([file])));
+ return [...current, ...added];
+ });
+ };
+
+ const handleSave = async (): Promise => {
+ const nextErrors = fieldErrors(form);
+ if (hasFieldErrors(nextErrors)) {
+ setShowErrors(true);
+ return;
+ }
+ if (!isLicense(form.license) || !isAccess(form.accessType)) return;
+
+ setSaving(true);
+ try {
+ let datasetId = createdIdRef.current;
+ if (!datasetId) {
+ const created = await GraphQL(createDatasetMutation, ownerArgs, {
+ createInput: { datasetType: DatasetType.Data },
+ });
+ const newId = created.addDataset.data?.id;
+ if (!created.addDataset.success || !newId) {
+ toast(
+ created.addDataset.errors?.fieldErrors?.[0]?.messages[0] ??
+ 'Unable to create dataset'
+ );
+ return;
+ }
+ datasetId = String(newId);
+ createdIdRef.current = datasetId;
+ }
+
+ const titled = await GraphQL(updateDatasetTitleMutationDoc, ownerArgs, {
+ updateDatasetInput: {
+ dataset: datasetId,
+ title: form.name.trim(),
+ },
+ });
+ if (titled.updateDataset.__typename !== 'TypeDataset') {
+ toast(
+ titled.updateDataset.messages[0]?.message ??
+ 'Unable to save the dataset name.'
+ );
+ return;
+ }
+
+ const metadata = await GraphQL(updateMetadataMutationDoc, ownerArgs, {
+ UpdateMetadataInput: {
+ dataset: datasetId,
+ description: form.description.trim(),
+ sectors: form.sectors.map((sector) => sector.value),
+ license: form.license,
+ accessType: form.accessType,
+ metadata: [],
+ },
+ });
+ if (!metadata.addUpdateDatasetMetadata.success) {
+ toast(metadataErrorMessage(metadata.addUpdateDatasetMetadata.errors));
+ return;
+ }
+
+ const currentFileKey = fileKey(files);
+ if (currentFileKey && uploadedKeyRef.current !== currentFileKey) {
+ await GraphQL(createResourceFilesDoc, ownerArgs, {
+ fileResourceInput: {
+ dataset: datasetId,
+ files,
+ },
+ });
+ uploadedKeyRef.current = currentFileKey;
+ }
+
+ if (onConnect) {
+ await onConnect(datasetId);
+ } else if (useCaseId) {
+ const assigned = await GraphQL(assignUseCaseDatasetsDoc, ownerArgs, {
+ useCaseId,
+ datasetIds: Array.from(new Set([...connectedDatasetIds, datasetId])),
+ });
+ if (
+ !('id' in assigned.updateUsecaseDatasets) ||
+ !assigned.updateUsecaseDatasets.id
+ ) {
+ toast('Unable to connect the dataset to this Use Case.');
+ return;
+ }
+ } else {
+ toast('Unable to connect the dataset.');
+ return;
+ }
+
+ connectedRef.current = true;
+ createdIdRef.current = null;
+ toast(successMessage);
+ onAdded();
+ onClose();
+ } catch (error: unknown) {
+ toast(getErrorMessage(error, 'Unable to create the dataset right now.'));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ {
+ if (!next) {
+ if (selectMenuIsOpen()) return;
+ handleClose();
+ }
+ }}
+ >
+
+
+
+
+ Add Dataset
+
+
+ {intro}
+
+
+
+ Close
+
+
+
+
+
+
{
+ if (isUploadTab(next)) setTab(next);
+ }}
+ >
+
+
+ File Upload
+
+
+ Public Platform
+
+
+
+
+
+
+
+
+
+ Drag and drop files here, or click to browse.
+
+
+
+ Supported file types:
+
+
+ {RESOURCE_FILE_TYPES.map((type) => (
+
+ {type}
+
+ ))}
+
+
+
+
+ Maximum file size limit: 50MB
+
+
+
+
+
+ {files.length === 0 ? (
+
+ No files uploaded yet.
+
+ ) : (
+
+ {files.map((file) => {
+ const extension =
+ file.name.split('.').pop()?.toUpperCase() || 'FILE';
+ return (
+ {
+ setFiles((current) =>
+ current.filter(
+ (item) =>
+ fileKey([item]) !== fileKey([file])
+ )
+ );
+ }}
+ />
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ setForm((current) => ({ ...current, name: value }))
+ }
+ />
+
+ setForm((current) => ({ ...current, description: value }))
+ }
+ />
+
+ setForm((current) => ({
+ ...current,
+ sectors: selectedSectors(value),
+ }))
+ }
+ />
+
+
+
+
+
+
{
+ if (!isAccess(selected)) return;
+ setForm((current) => ({
+ ...current,
+ accessType: selected,
+ }));
+ }}
+ >
+
+
+ Open Access
+
+
+ Restricted Access
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddNewEntryDialog.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddNewEntryDialog.tsx
new file mode 100644
index 00000000..3e8a6dd3
--- /dev/null
+++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddNewEntryDialog.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { useState, type ReactNode } from 'react';
+import { Building2, Check, User } from 'lucide-react';
+import { Button, Dialog, Text } from 'opub-ui';
+
+import styles from './add-new-entry-dialog.module.scss';
+
+export type NewEntryKind = 'person' | 'organisation';
+
+const OPTIONS: Array<{
+ value: NewEntryKind;
+ label: string;
+ icon: typeof User;
+ description: (subject: string) => string;
+}> = [
+ {
+ value: 'person',
+ label: 'Person',
+ icon: User,
+ description: (subject) =>
+ `Add an individual who is contributing to, partnering on or supporting this ${subject.replace(/ /g, '\u00A0')}.`,
+ },
+ {
+ value: 'organisation',
+ label: 'Organisation',
+ icon: Building2,
+ description: () =>
+ "Add an organisation that isn't on CivicDataSpace yet. It becomes available to connect elsewhere too.",
+ },
+];
+
+function dialogHeading(heading: ReactNode) {
+ return heading as unknown as string;
+}
+
+interface AddNewEntryDialogProps {
+ open: boolean;
+ subject: string;
+ onOpenChange: (open: boolean) => void;
+ onContinue: (kind: NewEntryKind) => void;
+}
+
+export function AddNewEntryDialog({
+ open,
+ subject,
+ onOpenChange,
+ onContinue,
+}: AddNewEntryDialogProps) {
+ const [kind, setKind] = useState('person');
+ const [wasOpen, setWasOpen] = useState(open);
+ if (open !== wasOpen) {
+ setWasOpen(open);
+ if (open) setKind('person');
+ }
+
+ return (
+
+ );
+}
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..91c32dcc
--- /dev/null
+++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/AddOrganisationSheet.tsx
@@ -0,0 +1,248 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+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;
+ description?: string;
+ onConnect: (organizationId: string) => Promise;
+ onAdded: () => void;
+ onBusy?: (busy: boolean) => void;
+};
+
+export function AddOrganisationSheet({
+ open,
+ onClose,
+ description = 'Add an organisation to this Use Case.',
+ onConnect,
+ onAdded,
+ onBusy,
+}: AddOrganisationSheetProps) {
+ const { organizationTypes } = useOrganizationTypes();
+ const [formData, setFormData] = useState(initialFormData);
+ const [connecting, setConnecting] = useState(false);
+ const [wasOpen, setWasOpen] = useState(open);
+ if (open !== wasOpen) {
+ setWasOpen(open);
+ if (!open) setFormData(initialFormData);
+ }
+
+ const { mutateAsync: createOrganisation, isLoading: creating } = useMutation(
+ (input: OrganizationInput) =>
+ GraphQL(organizationCreationMutation, {}, { input })
+ );
+
+ const isSaving = creating || connecting;
+
+ 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;
+ }
+
+ setConnecting(true);
+ try {
+ await onConnect(res.createOrganization.id);
+ } finally {
+ setConnecting(false);
+ }
+ 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
+
+
+ {description}
+
+
+
+ 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 })
+ }
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/ContentBlocksRenderer.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/ContentBlocksRenderer.tsx
new file mode 100644
index 00000000..5c854755
--- /dev/null
+++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/ContentBlocksRenderer.tsx
@@ -0,0 +1,251 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { getResourceChartDetails } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/queries';
+import { IconExternalLink, IconPencil } from '@tabler/icons-react';
+import { useQuery } from '@tanstack/react-query';
+import { Tag, Text } from 'opub-ui';
+
+import { GraphQL } from '@/lib/api';
+import { RichTextRenderer } from '@/components/RichTextRenderer';
+import {
+ isBlockEmpty,
+ isHostedImageUrl,
+ isLegacyHtmlSummary,
+ parseUseCaseContent,
+ type ContentBlock,
+} from '../content-document';
+import styles from '../edit.module.scss';
+
+const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
+
+export function ContentBlocksRenderer({
+ summary,
+ className,
+}: {
+ summary?: string | null;
+ className?: string;
+}) {
+ if (!summary?.trim()) return null;
+
+ if (isLegacyHtmlSummary(summary)) {
+ return ;
+ }
+
+ const document = parseUseCaseContent(summary);
+ return (
+
+ {document.blocks
+ .filter((block) => !isBlockEmpty(block))
+ .map((block) => (
+
+ ))}
+
+ );
+}
+
+export function ContentBlockView({ block }: { block: ContentBlock }) {
+ switch (block.type) {
+ case 'text':
+ return ;
+ case 'image':
+ if (!isHostedImageUrl(block.src)) return null;
+ return (
+
+ {/* A pasted URL can be any host. next/image only allows configured hosts. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {block.caption?.trim() ? (
+
+ {block.caption}
+
+ ) : null}
+
+ );
+ case 'chart':
+ return (
+
+
+ {block.chartName || 'Chart'}
+
+ {block.chartId ? (
+
+ ) : null}
+ {block.caption?.trim() ? (
+
+ {block.caption}
+
+ ) : null}
+
+ );
+ case 'highlight':
+ return (
+
+ {block.title.trim() ? (
+
{block.title}
+ ) : null}
+ {block.body.trim() ? (
+
{block.body}
+ ) : null}
+
+ );
+ case 'link': {
+ const url = block.url.trim();
+ const safe = url.startsWith('http://') || url.startsWith('https://');
+ const label = block.label.trim() || 'Untitled link';
+ const title = (
+
+
+ {label}
+
+ );
+ return (
+
+ {safe ? (
+
+ {title}
+
+ ) : (
+ title
+ )}
+ {block.description?.trim() ? (
+
+ {block.description}
+
+ ) : null}
+
+ );
+ }
+ default:
+ return null;
+ }
+}
+
+export function SelectedChartPreview({
+ chartId,
+ chartKind,
+ chartName,
+}: {
+ chartId: string;
+ chartKind?: string;
+ chartName?: string;
+}) {
+ const params = useParams<{ entityType?: string; entitySlug?: string }>();
+ const { entityType, entitySlug } = params;
+ const canFetch = Boolean(entityType && entitySlug);
+ const isImage = chartKind === 'TypeResourceChartImage';
+
+ const chartQuery = useQuery(
+ [`usecase_block_chart_${chartId}`],
+ () => {
+ if (!entityType || !entitySlug) {
+ return Promise.resolve(null);
+ }
+ return GraphQL(
+ getResourceChartDetails,
+ { [entityType]: entitySlug },
+ { chartDetailsId: chartId }
+ );
+ },
+ { enabled: canFetch && !isImage }
+ );
+
+ const options = chartQuery.data?.resourceChart?.chart?.options;
+
+ if (!canFetch) return null;
+
+ if (!isImage && options && typeof options === 'object') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {chartQuery.isLoading
+ ? 'Loading chart...'
+ : chartName || 'Interactive chart'}
+
+
+ );
+}
+
+export function ReviewField({
+ label,
+ children,
+ icon,
+}: {
+ label: string;
+ children: React.ReactNode;
+ icon?: React.ReactNode;
+}) {
+ return (
+
+
+ {icon ? (
+ {icon}
+ ) : null}
+ {label}
+
+ {children}
+
+ );
+}
+
+export function TagList({
+ items,
+ empty = 'None connected.',
+}: {
+ items?: Array<{ id?: string; label: string }>;
+ empty?: string;
+}) {
+ if (!items?.length) {
+ return {empty};
+ }
+ return (
+
+ {items.map((item, index) => (
+
+ {item.label}
+
+ ))}
+
+ );
+}
+
+export function editAction(label: string, onAction: () => void) {
+ return [
+ {
+ icon: IconPencil,
+ content: label,
+ onAction,
+ },
+ ];
+}
diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/ContentEditor.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/ContentEditor.tsx
new file mode 100644
index 00000000..5b80e899
--- /dev/null
+++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/ContentEditor.tsx
@@ -0,0 +1,691 @@
+'use client';
+
+import {
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type FocusEvent,
+} from 'react';
+import dynamic from 'next/dynamic';
+import {
+ IconChartBar,
+ IconCheck,
+ IconChevronDown,
+ IconChevronUp,
+ IconCopy,
+ IconExternalLink,
+ IconLink,
+ IconPencil,
+ IconPhoto,
+ IconPlus,
+ IconQuote,
+ IconTextCaption,
+ IconTrash,
+} from '@tabler/icons-react';
+import { Button, Combobox, Icon, Popover, Text, TextField } from 'opub-ui';
+
+import {
+ ChartBlock,
+ ContentBlock,
+ ContentBlockType,
+ createBlockId,
+ HIGHLIGHT_TONES,
+ HighlightBlock,
+ ImageBlock,
+ isBlockEmpty,
+ isHostedImageUrl,
+ LinkBlock,
+ TextBlock,
+} from '../content-document';
+
+const ReactQuill = dynamic(() => import('react-quill-new'), {
+ ssr: false,
+ loading: () => (
+
+ ),
+});
+import styles from '../edit.module.scss';
+import {
+ ContentBlockView,
+ SelectedChartPreview,
+} from './ContentBlocksRenderer';
+
+import 'react-quill-new/dist/quill.snow.css';
+
+interface ChartOption {
+ id: string;
+ name: string;
+ kind?: string;
+}
+
+interface ContentEditorProps {
+ blocks: ContentBlock[];
+ onChange: (blocks: ContentBlock[]) => void;
+ onSave: (blocks: ContentBlock[]) => void;
+ chartOptions?: ChartOption[];
+ showErrors?: boolean;
+}
+
+const BLOCK_TYPES: Array<{
+ type: ContentBlockType;
+ label: string;
+ icon: typeof IconTextCaption;
+}> = [
+ { type: 'text', label: 'Text', icon: IconTextCaption },
+ { type: 'image', label: 'Image', icon: IconPhoto },
+ { type: 'chart', label: 'Chart', icon: IconChartBar },
+ { type: 'highlight', label: 'Highlight', icon: IconQuote },
+ { type: 'link', label: 'Link / Embed', icon: IconLink },
+];
+
+function createBlock(type: ContentBlockType): ContentBlock {
+ const id = createBlockId();
+ switch (type) {
+ case 'image':
+ return { id, type, src: '', caption: '' };
+ case 'chart':
+ return { id, type, chartId: '', chartName: '', caption: '' };
+ case 'highlight':
+ return { id, type, title: '', body: '', tone: 'blue' };
+ case 'link':
+ return { id, type, label: '', url: '', description: '' };
+ default:
+ return { id, type: 'text', html: '' };
+ }
+}
+
+export function ContentEditor({
+ blocks,
+ onChange,
+ onSave,
+ chartOptions = [],
+ showErrors,
+}: ContentEditorProps) {
+ const [activeId, setActiveId] = useState(null);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const blocksRef = useRef(blocks);
+ useEffect(() => {
+ blocksRef.current = blocks;
+ });
+
+ const updateBlock = (id: string, next: ContentBlock, persist = false) => {
+ const nextBlocks = blocksRef.current.map((block) =>
+ block.id === id ? next : block
+ );
+ blocksRef.current = nextBlocks;
+ onChange(nextBlocks);
+ if (persist) onSave(nextBlocks);
+ };
+
+ const toggleBlock = (id: string) => {
+ if (activeId === id) {
+ const focused = document.activeElement;
+ if (focused instanceof HTMLElement) focused.blur();
+ onSave(blocksRef.current);
+ setActiveId(null);
+ return;
+ }
+ setActiveId(id);
+ };
+
+ const addBlock = (type: ContentBlockType) => {
+ const next = createBlock(type);
+ if (next.type === 'highlight') {
+ const used = blocks.filter((item) => item.type === 'highlight').length;
+ next.tone = HIGHLIGHT_TONES[used % HIGHLIGHT_TONES.length];
+ }
+ const nextBlocks = [...blocks, next];
+ onChange(nextBlocks);
+ onSave(nextBlocks);
+ setActiveId(next.id);
+ setMenuOpen(false);
+ };
+
+ const moveBlock = (index: number, direction: -1 | 1) => {
+ const target = index + direction;
+ if (target < 0 || target >= blocks.length) return;
+ const nextBlocks = [...blocks];
+ const [item] = nextBlocks.splice(index, 1);
+ nextBlocks.splice(target, 0, item);
+ onChange(nextBlocks);
+ onSave(nextBlocks);
+ };
+
+ const duplicateBlock = (index: number) => {
+ const copy = { ...blocks[index], id: createBlockId() };
+ const nextBlocks = [
+ ...blocks.slice(0, index + 1),
+ copy,
+ ...blocks.slice(index + 1),
+ ];
+ onChange(nextBlocks);
+ onSave(nextBlocks);
+ setActiveId(copy.id);
+ };
+
+ const deleteBlock = (id: string) => {
+ const nextBlocks = blocks.filter((block) => block.id !== id);
+ onChange(nextBlocks);
+ onSave(nextBlocks);
+ if (activeId === id) setActiveId(null);
+ };
+
+ return (
+
+ {blocks.length === 0 ? (
+
+
Your Use Case is ready to be built.
+
+
+ ) : (
+ <>
+
+ {blocks.map((block, index) => {
+ const active = activeId === block.id;
+
+ return (
+ setActiveId(block.id)}
+ onToggle={() => toggleBlock(block.id)}
+ onMoveUp={() => moveBlock(index, -1)}
+ onMoveDown={() => moveBlock(index, 1)}
+ onDuplicate={() => duplicateBlock(index)}
+ onDelete={() => deleteBlock(block.id)}
+ >
+ {!active ? (
+ isBlockEmpty(block) ? (
+ Empty {block.type} block
+ ) : (
+
+ )
+ ) : null}
+ {active && block.type === 'text' ? (
+ updateBlock(block.id, next)}
+ onBlur={(next) => updateBlock(block.id, next, true)}
+ />
+ ) : null}
+ {active && block.type === 'image' ? (
+
+ updateBlock(block.id, next, persist)
+ }
+ />
+ ) : null}
+ {active && block.type === 'chart' ? (
+ updateBlock(block.id, next)}
+ onBlur={(next) => updateBlock(block.id, next, true)}
+ />
+ ) : null}
+ {active && block.type === 'highlight' ? (
+ updateBlock(block.id, next)}
+ onBlur={(next) => updateBlock(block.id, next, true)}
+ />
+ ) : null}
+ {active && block.type === 'link' ? (
+ updateBlock(block.id, next)}
+ onBlur={(next) => updateBlock(block.id, next, true)}
+ />
+ ) : null}
+
+ );
+ })}
+
+
+ >
+ )}
+
+ );
+}
+
+function AddContentMenu({
+ open,
+ onOpenChange,
+ onSelect,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSelect: (type: ContentBlockType) => void;
+}) {
+ return (
+
+
+ }>
+ Add Content
+
+
+
+
+ {BLOCK_TYPES.map((item) => (
+
+ ))}
+
+
+
+ );
+}
+
+function BlockFrame({
+ children,
+ active,
+ isFirst,
+ isLast,
+ onActivate,
+ onToggle,
+ onMoveUp,
+ onMoveDown,
+ onDuplicate,
+ onDelete,
+}: {
+ children: React.ReactNode;
+ active: boolean;
+ isFirst: boolean;
+ isLast: boolean;
+ onActivate: () => void;
+ onToggle: () => void;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ onDuplicate: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function TextBlockEditor({
+ block,
+ onChange,
+ onBlur,
+}: {
+ block: TextBlock;
+ onChange: (block: TextBlock) => void;
+ onBlur: (block: TextBlock) => void;
+}) {
+ const modules = useMemo(
+ () => ({
+ toolbar: [
+ [{ header: [2, 3, false] }],
+ ['blockquote'],
+ ['bold', 'italic', 'underline'],
+ [{ list: 'bullet' }, { list: 'ordered' }],
+ ['link'],
+ ],
+ }),
+ []
+ );
+
+ return (
+
+ onChange({ ...block, html: value })}
+ onBlur={(_range, _source, editor) =>
+ onBlur({ ...block, html: editor.getHTML() })
+ }
+ />
+
+ );
+}
+
+function ImageBlockEditor({
+ block,
+ onChange,
+ showErrors,
+}: {
+ block: ImageBlock;
+ onChange: (block: ImageBlock, persist: boolean) => void;
+ showErrors?: boolean;
+}) {
+ const imageUrl =
+ block.src.startsWith('data:') || block.src.startsWith('blob:')
+ ? ''
+ : block.src;
+ const previewUrl = isHostedImageUrl(imageUrl) ? imageUrl : '';
+
+ return (
+
+ {previewUrl ? (
+ // A pasted URL can be any host. next/image only allows configured hosts.
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+ {showErrors && !previewUrl ? (
+
+ Paste an image URL
+
+ ) : null}
+ onChange({ ...block, src: value }, false)}
+ onBlur={(value) => onChange({ ...block, src: value.trim() }, 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 })}
+ />
+
+ );
+}
+
+function fieldValue(event: FocusEvent | undefined, fallback: string) {
+ const target = event?.currentTarget ?? event?.target;
+ return target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLInputElement
+ ? target.value
+ : fallback;
+}
+
+function sizeHighlightFields(root: HTMLElement | null) {
+ if (!root) return;
+ root.querySelectorAll('textarea').forEach((area) => {
+ area.style.height = 'auto';
+ area.style.height = `${area.scrollHeight}px`;
+ });
+}
+
+function HighlightBlockEditor({
+ block,
+ onChange,
+ onBlur,
+}: {
+ block: HighlightBlock;
+ onChange: (block: HighlightBlock) => void;
+ onBlur: (block: HighlightBlock) => void;
+}) {
+ const rootRef = useRef(null);
+
+ useLayoutEffect(() => {
+ sizeHighlightFields(rootRef.current);
+ }, [block.title, block.body]);
+
+ return (
+
+
+ onChange({ ...block, title: value })}
+ onBlur={(event) =>
+ onBlur({ ...block, title: fieldValue(event, block.title) })
+ }
+ />
+
+
+ onChange({ ...block, body: value })}
+ onBlur={(event) =>
+ onBlur({ ...block, body: fieldValue(event, block.body) })
+ }
+ />
+
+
+ );
+}
+
+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..59bec6ff
--- /dev/null
+++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EditLayout.tsx
@@ -0,0 +1,228 @@
+'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}`);
+ })
+ .catch(() => undefined);
+ };
+
+ 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..eb5fea57
--- /dev/null
+++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx
@@ -0,0 +1,573 @@
+'use client';
+
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react';
+import Image from 'next/image';
+import { Button, Icon, Select, 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 [trackedOpen, setTrackedOpen] = useState(open);
+ if (open !== trackedOpen) {
+ setTrackedOpen(open);
+ if (!open) {
+ setMenuRect(null);
+ setListEl(null);
+ }
+ }
+ const [awaitingResults, setAwaitingResults] = useState(false);
+ const [pendingAdds, setPendingAdds] = useState([]);
+ const [removingIds, setRemovingIds] = useState([]);
+ const [seenSelectedKey, setSeenSelectedKey] = 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);
+ useEffect(() => {
+ 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) 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 selectedKey = selected.map((item) => item.id).join('\0');
+ if (selectedKey !== seenSelectedKey) {
+ setSeenSelectedKey(selectedKey);
+ setPendingAdds((prev) => {
+ const next = prev.filter((item) => !selectedIds.has(item.id));
+ return next.length === prev.length ? prev : next;
+ });
+ setRemovingIds((prev) => {
+ const next = prev.filter((id) => selectedIds.has(id));
+ return next.length === prev.length ? prev : next;
+ });
+ }
+ 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(() => {
+ 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]);
+
+ const searchField = (
+
+